mirror of
https://github.com/chatmail/core.git
synced 2026-04-01 21:12:13 +03:00
Compare commits
399 Commits
v2.27.0
...
1b42e74b52
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b42e74b52 | ||
|
|
d7b3a85127 | ||
|
|
822a99ea9c | ||
|
|
bf02785a36 | ||
|
|
01b2aa0f66 | ||
|
|
fb46c34b55 | ||
|
|
9393753190 | ||
|
|
d9056fd187 | ||
|
|
7b17b1f8b8 | ||
|
|
d8d7f12af0 | ||
|
|
0150d38ddd | ||
|
|
11b6a108f5 | ||
|
|
54858361a9 | ||
|
|
6a705a3ef6 | ||
|
|
a23e41ea6d | ||
|
|
bdca3e5c09 | ||
|
|
a61a25f139 | ||
|
|
5404e683eb | ||
|
|
80acc9d467 | ||
|
|
3c5af7a559 | ||
|
|
f7e9973fb4 | ||
|
|
c0a3d77301 | ||
|
|
9891c2a531 | ||
|
|
f85c625799 | ||
|
|
b30f93a57d | ||
|
|
a95bf77868 | ||
|
|
d26fa715b5 | ||
|
|
1b43aac356 | ||
|
|
53acfaa054 | ||
|
|
874e38c146 | ||
|
|
cce8e3bc5a | ||
|
|
1e20055523 | ||
|
|
abb93cd79d | ||
|
|
5f84be718a | ||
|
|
d1c3a679a0 | ||
|
|
0c4e32363e | ||
|
|
89b5675b83 | ||
|
|
8ff8ba7416 | ||
|
|
e3a7d555a8 | ||
|
|
964bbad53e | ||
|
|
a1eb376131 | ||
|
|
3c4ce17f1e | ||
|
|
0622289420 | ||
|
|
c928015f20 | ||
|
|
b10acd194e | ||
|
|
b94792706a | ||
|
|
bfae2296b7 | ||
|
|
e7625ca231 | ||
|
|
ab08a47298 | ||
|
|
b85fa84a37 | ||
|
|
ccd3caf4a7 | ||
|
|
5f248954dc | ||
|
|
a6c7958739 | ||
|
|
c724e2981c | ||
|
|
ffd9f80f8b | ||
|
|
42cb9fe890 | ||
|
|
914486cb32 | ||
|
|
526b3b0271 | ||
|
|
1c439b5ef4 | ||
|
|
f97c75f146 | ||
|
|
76a36a35bf | ||
|
|
dc4249a2ff | ||
|
|
957c0b7c56 | ||
|
|
8df9b9e4d9 | ||
|
|
692e1019b0 | ||
|
|
2511b03726 | ||
|
|
c39651a8d4 | ||
|
|
8230336936 | ||
|
|
e1e8407905 | ||
|
|
ffce0dfc9a | ||
|
|
e2eec2f1f8 | ||
|
|
072c0061ee | ||
|
|
cb783ffc12 | ||
|
|
af182a85a3 | ||
|
|
7d8989a068 | ||
|
|
d7bf10d7a4 | ||
|
|
f1e90c73cd | ||
|
|
c39d2f42ef | ||
|
|
e60f4ff70a | ||
|
|
ba64d8d19b | ||
|
|
4041d9a54e | ||
|
|
bbf9a86bce | ||
|
|
cdb0e0ce29 | ||
|
|
0e7f3c8238 | ||
|
|
16c85a9585 | ||
|
|
ff7023580f | ||
|
|
58d457140e | ||
|
|
b531a3c012 | ||
|
|
f055f6226c | ||
|
|
e95dca87bd | ||
|
|
0d9442458a | ||
|
|
60cf483270 | ||
|
|
598d759b8d | ||
|
|
10b93b3943 | ||
|
|
5a06d08613 | ||
|
|
85de4bf678 | ||
|
|
624fc394d9 | ||
|
|
9deba0cf2a | ||
|
|
b95d28b2d9 | ||
|
|
2131f5e9c0 | ||
|
|
a63f695b85 | ||
|
|
de25eb90ff | ||
|
|
3fdda6f3b8 | ||
|
|
c475882727 | ||
|
|
166e259b18 | ||
|
|
cc38298163 | ||
|
|
983f43c33c | ||
|
|
5028842fd5 | ||
|
|
e78b509d0a | ||
|
|
583979c6fc | ||
|
|
5bfd8dd517 | ||
|
|
32b0ca81f8 | ||
|
|
8dd7e5c5dd | ||
|
|
5bb0b86f6a | ||
|
|
ed2b0e8f03 | ||
|
|
8152ff518e | ||
|
|
cbcfb7087e | ||
|
|
396104af47 | ||
|
|
69f6727751 | ||
|
|
b72a677f4c | ||
|
|
00e78eecf6 | ||
|
|
8b0621b724 | ||
|
|
63bf4c4f33 | ||
|
|
d6bce56d18 | ||
|
|
c8dec0dcdd | ||
|
|
509644ea5f | ||
|
|
3e95239e71 | ||
|
|
74d4b823d2 | ||
|
|
1bcfb90b90 | ||
|
|
411ee511ed | ||
|
|
e5a30c341c | ||
|
|
3d409c37a1 | ||
|
|
b46c86c9b7 | ||
|
|
e5e268f503 | ||
|
|
633536bb13 | ||
|
|
94ee485155 | ||
|
|
ec0dc8bcad | ||
|
|
49296e3014 | ||
|
|
2b93e856e4 | ||
|
|
c5be7df1d7 | ||
|
|
6b74cb6539 | ||
|
|
de2ac8cca2 | ||
|
|
085fcd2751 | ||
|
|
83f30e4a54 | ||
|
|
e79b4baa09 | ||
|
|
1e0c0d8efa | ||
|
|
378fb09c80 | ||
|
|
ff2fbebff0 | ||
|
|
50a73666fd | ||
|
|
61a8eff2ad | ||
|
|
cbd379fdf0 | ||
|
|
fe826f762e | ||
|
|
2019debe99 | ||
|
|
6c4f4bfd19 | ||
|
|
44b0736216 | ||
|
|
3b29469102 | ||
|
|
6325a35b5b | ||
|
|
c08644490a | ||
|
|
955f79923a | ||
|
|
c9026bff2c | ||
|
|
4fc0d0f53d | ||
|
|
1bf24618fa | ||
|
|
3f98e45c29 | ||
|
|
26ddcfaaed | ||
|
|
f0a12d493c | ||
|
|
c848ea7eda | ||
|
|
7c55356271 | ||
|
|
f4ee01ecca | ||
|
|
448c0d2268 | ||
|
|
3325270896 | ||
|
|
b563064b26 | ||
|
|
8d32d3ae0c | ||
|
|
c5f19f67a9 | ||
|
|
baeb31b5fa | ||
|
|
5d3bc00fd5 | ||
|
|
424928b660 | ||
|
|
1b8c732611 | ||
|
|
2531dfea1d | ||
|
|
9003b248aa | ||
|
|
35875f9b32 | ||
|
|
008e6c4af3 | ||
|
|
a6baba1852 | ||
|
|
a6b2a54e46 | ||
|
|
99aa99eb5b | ||
|
|
566395f1fa | ||
|
|
4ccd3cb665 | ||
|
|
f5e1e2678b | ||
|
|
c3a5e3ac0d | ||
|
|
b2f31c8148 | ||
|
|
29c57ad065 | ||
|
|
82a0d6b0ab | ||
|
|
5ff323ce15 | ||
|
|
a67a5299bf | ||
|
|
659d21aa9d | ||
|
|
8f604e74ec | ||
|
|
e1ebf3e96d | ||
|
|
76171aea2e | ||
|
|
96b8d1720e | ||
|
|
47b49fd02e | ||
|
|
f50e3d6ffa | ||
|
|
2ecb537307 | ||
|
|
ccae73f6db | ||
|
|
fce91f3ee0 | ||
|
|
d446a16fc6 | ||
|
|
c3a6e48882 | ||
|
|
46ec3a469b | ||
|
|
fe3b1ea16d | ||
|
|
ed300b6f97 | ||
|
|
e456be4e21 | ||
|
|
ba4055b7df | ||
|
|
c06f53cb86 | ||
|
|
13dafa46b5 | ||
|
|
d552250dc4 | ||
|
|
1383e790c3 | ||
|
|
b536902827 | ||
|
|
2631745a57 | ||
|
|
46bbe5f077 | ||
|
|
0f14edd5d9 | ||
|
|
fe6e942191 | ||
|
|
67aac12995 | ||
|
|
f2fb59f0cc | ||
|
|
55ab1b86f7 | ||
|
|
ceba687df3 | ||
|
|
7e811469b3 | ||
|
|
cdacad235e | ||
|
|
c766397abc | ||
|
|
14a59afd5d | ||
|
|
9c883e6424 | ||
|
|
9d7db20225 | ||
|
|
fdb583b5e9 | ||
|
|
8d6f4b0354 | ||
|
|
284469363e | ||
|
|
6078c79020 | ||
|
|
161e5ae358 | ||
|
|
a66859ebf2 | ||
|
|
de902babc3 | ||
|
|
98a8679779 | ||
|
|
8ce3ecc809 | ||
|
|
50a1f907a5 | ||
|
|
c2bf2a32b5 | ||
|
|
84710384fe | ||
|
|
f60fce22ed | ||
|
|
0d2f2b3266 | ||
|
|
516f0a1a98 | ||
|
|
25750de4e1 | ||
|
|
a89ce8ce7a | ||
|
|
9ac64ea6b9 | ||
|
|
294e23d82d | ||
|
|
184736723f | ||
|
|
cea528ed61 | ||
|
|
9b11f53da6 | ||
|
|
5c339efb70 | ||
|
|
d71c163c7d | ||
|
|
19fde9594f | ||
|
|
20b3a06adf | ||
|
|
b0127fa381 | ||
|
|
6a293aebe2 | ||
|
|
fd90493766 | ||
|
|
b1883c802b | ||
|
|
71ee32b8b7 | ||
|
|
84161f4202 | ||
|
|
4af9463a91 | ||
|
|
ddd4fc49a2 | ||
|
|
7d5bedde4d | ||
|
|
e34fee72a0 | ||
|
|
7ba4a43253 | ||
|
|
a09fd4577a | ||
|
|
525a3539d2 | ||
|
|
fbcdd45015 | ||
|
|
1ea8ed6442 | ||
|
|
f6817131b8 | ||
|
|
28fc1d2ff2 | ||
|
|
5925f72316 | ||
|
|
8dfa5fc37e | ||
|
|
49b04e8789 | ||
|
|
d87d87f467 | ||
|
|
bf72b3ad49 | ||
|
|
30f2981259 | ||
|
|
121bfd1fa8 | ||
|
|
9e2a4325e9 | ||
|
|
4509c1bd06 | ||
|
|
3133d89dcc | ||
|
|
99775458c4 | ||
|
|
e432960246 | ||
|
|
58cd133b5c | ||
|
|
3d234e7fc7 | ||
|
|
595258ae05 | ||
|
|
06b2a890da | ||
|
|
95ed31391d | ||
|
|
98944efdb8 | ||
|
|
3f27be9bcb | ||
|
|
5902fe2cbe | ||
|
|
73e0f81e83 | ||
|
|
cbe842735e | ||
|
|
72bc9f0ae4 | ||
|
|
0defa117a0 | ||
|
|
3821cfab0c | ||
|
|
09f159991e | ||
|
|
014d2ace76 | ||
|
|
646728372b | ||
|
|
7c30aef2ed | ||
|
|
c38d02728e | ||
|
|
dea1b414db | ||
|
|
aa5ee19340 | ||
|
|
9271ecd208 | ||
|
|
952f6735a2 | ||
|
|
a50aa3b6e9 | ||
|
|
23d95df66a | ||
|
|
6db2cf6144 | ||
|
|
47c1e54219 | ||
|
|
b41c309e21 | ||
|
|
f7ae2abe52 | ||
|
|
3a7f82c66e | ||
|
|
d75a78d446 | ||
|
|
676132457f | ||
|
|
8bce137e06 | ||
|
|
f359a9c451 | ||
|
|
0d97a5b511 | ||
|
|
7ccc021aea | ||
|
|
08e9cdc487 | ||
|
|
12cee23924 | ||
|
|
5fb118e5a3 | ||
|
|
1ec3f45dc1 | ||
|
|
e4e19b57b3 | ||
|
|
2efb128fec | ||
|
|
4a5d5bdeb1 | ||
|
|
cde4a61be7 | ||
|
|
ca2b4d7a6f | ||
|
|
ef61c0c408 | ||
|
|
dc5f939ac6 | ||
|
|
2c0092738f | ||
|
|
b1e6cf2052 | ||
|
|
343dca87f7 | ||
|
|
4d06f5a8ae | ||
|
|
a4bec7dc70 | ||
|
|
1f32c5ab40 | ||
|
|
43e8d5cc6c | ||
|
|
7e4547582e | ||
|
|
721b9cebef | ||
|
|
0d50d8703f | ||
|
|
9aba299c75 | ||
|
|
2854f87a9d | ||
|
|
145a5813e8 | ||
|
|
4cb129a67e | ||
|
|
7bf7ec3d32 | ||
|
|
8a7498a9a8 | ||
|
|
c41a69ea1e | ||
|
|
38a547dfda | ||
|
|
7c998af973 | ||
|
|
6b6ec2a4b7 | ||
|
|
b1fa1055d7 | ||
|
|
15ce05b0c7 | ||
|
|
8112183429 | ||
|
|
b9ae74fab2 | ||
|
|
fdff90eba4 | ||
|
|
a4a54d3648 | ||
|
|
98fb760f49 | ||
|
|
f553c094eb | ||
|
|
bbb4bed996 | ||
|
|
25088f2dcb | ||
|
|
552e9f4052 | ||
|
|
b3616a013f | ||
|
|
c378b1218d | ||
|
|
21f6e7c676 | ||
|
|
ac543ad251 | ||
|
|
183898b137 | ||
|
|
56204ae701 | ||
|
|
7906405400 | ||
|
|
531e0bc914 | ||
|
|
3637fe67a7 | ||
|
|
8eef79f95d | ||
|
|
6077499f07 | ||
|
|
94d2d8cfd7 | ||
|
|
ba3cad6ad6 | ||
|
|
c9c362d5ff | ||
|
|
6514b4ca7f | ||
|
|
e7e31d7914 | ||
|
|
51d6855e0d | ||
|
|
2f90b55309 | ||
|
|
be3e202470 | ||
|
|
57aadfbbf6 | ||
|
|
849cde9757 | ||
|
|
b4cd99fc56 | ||
|
|
9305a0676c | ||
|
|
39c9ba19ef | ||
|
|
af574279fd | ||
|
|
713c929e03 | ||
|
|
c83c131a37 | ||
|
|
0d0602a4a5 | ||
|
|
abfb556377 | ||
|
|
72788daca0 | ||
|
|
16bd87c78f | ||
|
|
d44e2420bc | ||
|
|
88d213fcdb | ||
|
|
fb14acb0fb | ||
|
|
a5c470fbae | ||
|
|
6bdba33d32 | ||
|
|
c6ace749e3 | ||
|
|
22ebd6436f |
100
.github/workflows/ci.yml
vendored
100
.github/workflows/ci.yml
vendored
@@ -20,17 +20,18 @@ permissions: {}
|
||||
|
||||
env:
|
||||
RUSTFLAGS: -Dwarnings
|
||||
RUST_VERSION: 1.91.0
|
||||
RUST_VERSION: 1.94.0
|
||||
|
||||
# Minimum Supported Rust Version
|
||||
MSRV: 1.85.0
|
||||
MSRV: 1.88.0
|
||||
|
||||
jobs:
|
||||
lint_rust:
|
||||
name: Lint Rust
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -39,7 +40,7 @@ jobs:
|
||||
- run: rustup override set $RUST_VERSION
|
||||
shell: bash
|
||||
- name: Cache rust cargo artifacts
|
||||
uses: swatinem/rust-cache@v2
|
||||
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
|
||||
- name: Run rustfmt
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Run clippy
|
||||
@@ -52,22 +53,24 @@ jobs:
|
||||
cargo_deny:
|
||||
name: cargo deny
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: EmbarkStudios/cargo-deny-action@v2
|
||||
- uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979
|
||||
with:
|
||||
arguments: --all-features --workspace
|
||||
arguments: --workspace --all-features --locked
|
||||
command: check
|
||||
command-arguments: "-Dwarnings"
|
||||
|
||||
provider_database:
|
||||
name: Check provider database
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -79,15 +82,16 @@ jobs:
|
||||
docs:
|
||||
name: Rust doc comments
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
RUSTDOCFLAGS: -Dwarnings
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- name: Cache rust cargo artifacts
|
||||
uses: swatinem/rust-cache@v2
|
||||
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
|
||||
- name: Rustdoc
|
||||
run: cargo doc --document-private-items --no-deps
|
||||
|
||||
@@ -107,6 +111,7 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
rust: minimum
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- run:
|
||||
echo "RUSTUP_TOOLCHAIN=$MSRV" >> $GITHUB_ENV
|
||||
@@ -117,7 +122,7 @@ jobs:
|
||||
shell: bash
|
||||
if: matrix.rust == 'latest'
|
||||
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -129,10 +134,10 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Cache rust cargo artifacts
|
||||
uses: swatinem/rust-cache@v2
|
||||
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@v2
|
||||
uses: taiki-e/install-action@69e777b377e4ec209ddad9426ae3e0c1008b0ef3
|
||||
with:
|
||||
tool: nextest
|
||||
|
||||
@@ -155,20 +160,21 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Cache rust cargo artifacts
|
||||
uses: swatinem/rust-cache@v2
|
||||
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
|
||||
|
||||
- name: Build C library
|
||||
run: cargo build -p deltachat_ffi
|
||||
|
||||
- name: Upload C library
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ${{ matrix.os }}-libdeltachat.a
|
||||
path: target/debug/libdeltachat.a
|
||||
@@ -180,20 +186,21 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Cache rust cargo artifacts
|
||||
uses: swatinem/rust-cache@v2
|
||||
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
|
||||
|
||||
- name: Build deltachat-rpc-server
|
||||
run: cargo build -p deltachat-rpc-server
|
||||
|
||||
- name: Upload deltachat-rpc-server
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ${{ matrix.os }}-deltachat-rpc-server
|
||||
path: ${{ matrix.os == 'windows-latest' && 'target/debug/deltachat-rpc-server.exe' || 'target/debug/deltachat-rpc-server' }}
|
||||
@@ -202,8 +209,9 @@ jobs:
|
||||
python_lint:
|
||||
name: Python lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -219,6 +227,38 @@ jobs:
|
||||
working-directory: deltachat-rpc-client
|
||||
run: tox -e lint
|
||||
|
||||
# mypy does not work with PyPy since mypy 1.19
|
||||
# as it introduced native `librt` dependency
|
||||
# that uses CPython internals.
|
||||
# We only run mypy with CPython because of this.
|
||||
cffi_python_mypy:
|
||||
name: CFFI Python mypy
|
||||
needs: ["c_library", "python_lint"]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download libdeltachat.a
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ubuntu-latest-libdeltachat.a
|
||||
path: target/debug
|
||||
|
||||
- name: Install tox
|
||||
run: pip install tox
|
||||
|
||||
- name: Run mypy
|
||||
env:
|
||||
DCC_RS_TARGET: debug
|
||||
DCC_RS_DEV: ${{ github.workspace }}
|
||||
working-directory: python
|
||||
run: tox -e mypy
|
||||
|
||||
|
||||
cffi_python_tests:
|
||||
name: CFFI Python tests
|
||||
needs: ["c_library", "python_lint"]
|
||||
@@ -238,21 +278,22 @@ jobs:
|
||||
- os: macos-latest
|
||||
python: pypy3.10
|
||||
|
||||
# Minimum Supported Python Version = 3.8
|
||||
# Minimum Supported Python Version = 3.10
|
||||
# This is the minimum version for which manylinux Python wheels are
|
||||
# built. Test it with minimum supported Rust version.
|
||||
- os: ubuntu-latest
|
||||
python: 3.8
|
||||
python: "3.10"
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download libdeltachat.a
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.os }}-libdeltachat.a
|
||||
path: target/debug
|
||||
@@ -271,7 +312,7 @@ jobs:
|
||||
DCC_RS_TARGET: debug
|
||||
DCC_RS_DEV: ${{ github.workspace }}
|
||||
working-directory: python
|
||||
run: tox -e mypy,doc,py
|
||||
run: tox -e doc,py
|
||||
|
||||
rpc_python_tests:
|
||||
name: JSON-RPC Python tests
|
||||
@@ -293,13 +334,14 @@ jobs:
|
||||
- os: macos-latest
|
||||
python: pypy3.10
|
||||
|
||||
# Minimum Supported Python Version = 3.8
|
||||
# Minimum Supported Python Version = 3.10
|
||||
- os: ubuntu-latest
|
||||
python: 3.8
|
||||
python: "3.10"
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -313,7 +355,7 @@ jobs:
|
||||
run: pip install tox
|
||||
|
||||
- name: Download deltachat-rpc-server
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.os }}-deltachat-rpc-server
|
||||
path: target/debug
|
||||
|
||||
253
.github/workflows/deltachat-rpc-server.yml
vendored
253
.github/workflows/deltachat-rpc-server.yml
vendored
@@ -30,22 +30,46 @@ jobs:
|
||||
arch: [aarch64, armv7l, armv6l, i686, x86_64]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
|
||||
- name: Build deltachat-rpc-server binaries
|
||||
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux
|
||||
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: deltachat-rpc-server-${{ matrix.arch }}-linux
|
||||
path: result/bin/deltachat-rpc-server
|
||||
if-no-files-found: error
|
||||
|
||||
build_linux_wheel:
|
||||
name: Linux wheel
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [aarch64, armv7l, armv6l, i686, x86_64]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
|
||||
- name: Build deltachat-rpc-server wheels
|
||||
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux-wheel
|
||||
|
||||
- name: Upload wheel
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: deltachat-rpc-server-${{ matrix.arch }}-linux-wheel
|
||||
path: result/*.whl
|
||||
if-no-files-found: error
|
||||
|
||||
build_windows:
|
||||
name: Windows
|
||||
strategy:
|
||||
@@ -54,22 +78,46 @@ jobs:
|
||||
arch: [win32, win64]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
|
||||
- name: Build deltachat-rpc-server binaries
|
||||
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}
|
||||
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: deltachat-rpc-server-${{ matrix.arch }}
|
||||
path: result/bin/deltachat-rpc-server.exe
|
||||
if-no-files-found: error
|
||||
|
||||
build_windows_wheel:
|
||||
name: Windows wheel
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [win32, win64]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
|
||||
- name: Build deltachat-rpc-server wheels
|
||||
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-wheel
|
||||
|
||||
- name: Upload wheel
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: deltachat-rpc-server-${{ matrix.arch }}-wheel
|
||||
path: result/*.whl
|
||||
if-no-files-found: error
|
||||
|
||||
build_macos:
|
||||
name: macOS
|
||||
strategy:
|
||||
@@ -79,7 +127,7 @@ jobs:
|
||||
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -91,7 +139,7 @@ jobs:
|
||||
run: cargo build --release --package deltachat-rpc-server --target ${{ matrix.arch }}-apple-darwin --features vendored
|
||||
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: deltachat-rpc-server-${{ matrix.arch }}-macos
|
||||
path: target/${{ matrix.arch }}-apple-darwin/release/deltachat-rpc-server
|
||||
@@ -105,25 +153,49 @@ jobs:
|
||||
arch: [arm64-v8a, armeabi-v7a]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
|
||||
- name: Build deltachat-rpc-server binaries
|
||||
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android
|
||||
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: deltachat-rpc-server-${{ matrix.arch }}-android
|
||||
path: result/bin/deltachat-rpc-server
|
||||
if-no-files-found: error
|
||||
|
||||
build_android_wheel:
|
||||
name: Android wheel
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [arm64-v8a, armeabi-v7a]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
|
||||
- name: Build deltachat-rpc-server wheels
|
||||
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android-wheel
|
||||
|
||||
- name: Upload wheel
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: deltachat-rpc-server-${{ matrix.arch }}-android-wheel
|
||||
path: result/*.whl
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Build wheels and upload binaries to the release
|
||||
needs: ["build_linux", "build_windows", "build_macos"]
|
||||
needs: ["build_linux", "build_linux_wheel", "build_windows", "build_windows_wheel", "build_macos", "build_android", "build_android_wheel"]
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/p/deltachat-rpc-server
|
||||
@@ -132,78 +204,132 @@ jobs:
|
||||
contents: write
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
|
||||
- name: Download Linux aarch64 binary
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-aarch64-linux
|
||||
path: deltachat-rpc-server-aarch64-linux.d
|
||||
|
||||
- name: Download Linux aarch64 wheel
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-aarch64-linux-wheel
|
||||
path: deltachat-rpc-server-aarch64-linux-wheel.d
|
||||
|
||||
- name: Download Linux armv7l binary
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-armv7l-linux
|
||||
path: deltachat-rpc-server-armv7l-linux.d
|
||||
|
||||
- name: Download Linux armv7l wheel
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-armv7l-linux-wheel
|
||||
path: deltachat-rpc-server-armv7l-linux-wheel.d
|
||||
|
||||
- name: Download Linux armv6l binary
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-armv6l-linux
|
||||
path: deltachat-rpc-server-armv6l-linux.d
|
||||
|
||||
- name: Download Linux armv6l wheel
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-armv6l-linux-wheel
|
||||
path: deltachat-rpc-server-armv6l-linux-wheel.d
|
||||
|
||||
- name: Download Linux i686 binary
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-i686-linux
|
||||
path: deltachat-rpc-server-i686-linux.d
|
||||
|
||||
- name: Download Linux i686 wheel
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-i686-linux-wheel
|
||||
path: deltachat-rpc-server-i686-linux-wheel.d
|
||||
|
||||
- name: Download Linux x86_64 binary
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-x86_64-linux
|
||||
path: deltachat-rpc-server-x86_64-linux.d
|
||||
|
||||
- name: Download Linux x86_64 wheel
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-x86_64-linux-wheel
|
||||
path: deltachat-rpc-server-x86_64-linux-wheel.d
|
||||
|
||||
- name: Download Win32 binary
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-win32
|
||||
path: deltachat-rpc-server-win32.d
|
||||
|
||||
- name: Download Win32 wheel
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-win32-wheel
|
||||
path: deltachat-rpc-server-win32-wheel.d
|
||||
|
||||
- name: Download Win64 binary
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-win64
|
||||
path: deltachat-rpc-server-win64.d
|
||||
|
||||
- name: Download Win64 wheel
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-win64-wheel
|
||||
path: deltachat-rpc-server-win64-wheel.d
|
||||
|
||||
- name: Download macOS binary for x86_64
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
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@v6
|
||||
uses: actions/download-artifact@v7
|
||||
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@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-arm64-v8a-android
|
||||
path: deltachat-rpc-server-arm64-v8a-android.d
|
||||
|
||||
- name: Download Android wheel for arm64-v8a
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-arm64-v8a-android-wheel
|
||||
path: deltachat-rpc-server-arm64-v8a-android-wheel.d
|
||||
|
||||
- name: Download Android binary for armeabi-v7a
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-armeabi-v7a-android
|
||||
path: deltachat-rpc-server-armeabi-v7a-android.d
|
||||
|
||||
- name: Download Android wheel for armeabi-v7a
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-armeabi-v7a-android-wheel
|
||||
path: deltachat-rpc-server-armeabi-v7a-android-wheel.d
|
||||
|
||||
- name: Create bin/ directory
|
||||
run: |
|
||||
mkdir -p bin
|
||||
@@ -222,38 +348,21 @@ jobs:
|
||||
- name: List binaries
|
||||
run: ls -l bin/
|
||||
|
||||
# Python 3.11 is needed for tomllib used in scripts/wheel-rpc-server.py
|
||||
- name: Install python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
|
||||
- name: Install wheel
|
||||
run: pip install wheel
|
||||
|
||||
- name: Build deltachat-rpc-server Python wheels and source package
|
||||
- name: Build deltachat-rpc-server Python wheels
|
||||
run: |
|
||||
mkdir -p dist
|
||||
nix build .#deltachat-rpc-server-x86_64-linux-wheel
|
||||
cp result/*.whl dist/
|
||||
nix build .#deltachat-rpc-server-armv7l-linux-wheel
|
||||
cp result/*.whl dist/
|
||||
nix build .#deltachat-rpc-server-armv6l-linux-wheel
|
||||
cp result/*.whl dist/
|
||||
nix build .#deltachat-rpc-server-aarch64-linux-wheel
|
||||
cp result/*.whl dist/
|
||||
nix build .#deltachat-rpc-server-i686-linux-wheel
|
||||
cp result/*.whl dist/
|
||||
nix build .#deltachat-rpc-server-win64-wheel
|
||||
cp result/*.whl dist/
|
||||
nix build .#deltachat-rpc-server-win32-wheel
|
||||
cp result/*.whl dist/
|
||||
nix build .#deltachat-rpc-server-arm64-v8a-android-wheel
|
||||
cp result/*.whl dist/
|
||||
nix build .#deltachat-rpc-server-armeabi-v7a-android-wheel
|
||||
cp result/*.whl dist/
|
||||
nix build .#deltachat-rpc-server-source
|
||||
cp result/*.tar.gz dist/
|
||||
mv deltachat-rpc-server-aarch64-linux-wheel.d/*.whl dist/
|
||||
mv deltachat-rpc-server-armv7l-linux-wheel.d/*.whl dist/
|
||||
mv deltachat-rpc-server-armv6l-linux-wheel.d/*.whl dist/
|
||||
mv deltachat-rpc-server-i686-linux-wheel.d/*.whl dist/
|
||||
mv deltachat-rpc-server-x86_64-linux-wheel.d/*.whl dist/
|
||||
mv deltachat-rpc-server-win64-wheel.d/*.whl dist/
|
||||
mv deltachat-rpc-server-win32-wheel.d/*.whl dist/
|
||||
mv deltachat-rpc-server-arm64-v8a-android-wheel.d/*.whl dist/
|
||||
mv deltachat-rpc-server-armeabi-v7a-android-wheel.d/*.whl dist/
|
||||
python3 scripts/wheel-rpc-server.py x86_64-darwin bin/deltachat-rpc-server-x86_64-macos
|
||||
python3 scripts/wheel-rpc-server.py aarch64-darwin bin/deltachat-rpc-server-aarch64-macos
|
||||
mv *.whl dist/
|
||||
@@ -271,21 +380,24 @@ jobs:
|
||||
--repo ${{ github.repository }} \
|
||||
bin/* dist/*
|
||||
|
||||
- name: Publish deltachat-rpc-client to PyPI
|
||||
- name: Publish deltachat-rpc-server to PyPI
|
||||
if: github.event_name == 'release'
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e
|
||||
|
||||
publish_npm_package:
|
||||
name: Build & Publish npm prebuilds and deltachat-rpc-server
|
||||
needs: ["build_linux", "build_windows", "build_macos"]
|
||||
runs-on: "ubuntu-latest"
|
||||
environment:
|
||||
name: npm-stdio-rpc-server
|
||||
url: https://www.npmjs.com/package/@deltachat/stdio-rpc-server
|
||||
permissions:
|
||||
id-token: write
|
||||
|
||||
# Needed to publish the binaries to the release.
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -294,67 +406,67 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Download Linux aarch64 binary
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-aarch64-linux
|
||||
path: deltachat-rpc-server-aarch64-linux.d
|
||||
|
||||
- name: Download Linux armv7l binary
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-armv7l-linux
|
||||
path: deltachat-rpc-server-armv7l-linux.d
|
||||
|
||||
- name: Download Linux armv6l binary
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-armv6l-linux
|
||||
path: deltachat-rpc-server-armv6l-linux.d
|
||||
|
||||
- name: Download Linux i686 binary
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-i686-linux
|
||||
path: deltachat-rpc-server-i686-linux.d
|
||||
|
||||
- name: Download Linux x86_64 binary
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-x86_64-linux
|
||||
path: deltachat-rpc-server-x86_64-linux.d
|
||||
|
||||
- name: Download Win32 binary
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-win32
|
||||
path: deltachat-rpc-server-win32.d
|
||||
|
||||
- name: Download Win64 binary
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-win64
|
||||
path: deltachat-rpc-server-win64.d
|
||||
|
||||
- name: Download macOS binary for x86_64
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
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@v6
|
||||
uses: actions/download-artifact@v7
|
||||
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@v6
|
||||
uses: actions/download-artifact@v7
|
||||
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@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-armeabi-v7a-android
|
||||
path: deltachat-rpc-server-armeabi-v7a-android.d
|
||||
@@ -384,7 +496,7 @@ jobs:
|
||||
ls -lah
|
||||
|
||||
- name: Upload to artifacts
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: deltachat-rpc-server-npm-package
|
||||
path: deltachat-rpc-server/npm-package/*.tgz
|
||||
@@ -406,11 +518,14 @@ jobs:
|
||||
node-version: 20
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
# Ensure npm 11.5.1 or later is installed.
|
||||
# It is needed for <https://docs.npmjs.com/trusted-publishers>
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Publish npm packets for prebuilds and `@deltachat/stdio-rpc-server`
|
||||
if: github.event_name == 'release'
|
||||
working-directory: deltachat-rpc-server/npm-package
|
||||
run: |
|
||||
ls -lah platform_package
|
||||
for platform in *.tgz; do npm publish --provenance "$platform" --access public; done
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
12
.github/workflows/jsonrpc-client-npm-package.yml
vendored
12
.github/workflows/jsonrpc-client-npm-package.yml
vendored
@@ -10,11 +10,14 @@ jobs:
|
||||
pack-module:
|
||||
name: "Publish @deltachat/jsonrpc-client"
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: npm-jsonrpc-client
|
||||
url: https://www.npmjs.com/package/@deltachat/jsonrpc-client
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -24,6 +27,11 @@ jobs:
|
||||
node-version: 20
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
# Ensure npm 11.5.1 or later is installed.
|
||||
# It is needed for <https://docs.npmjs.com/trusted-publishers>
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install dependencies without running scripts
|
||||
working-directory: deltachat-jsonrpc/typescript
|
||||
run: npm install --ignore-scripts
|
||||
@@ -37,5 +45,3 @@ jobs:
|
||||
- name: Publish
|
||||
working-directory: deltachat-jsonrpc/typescript
|
||||
run: npm publish --provenance deltachat-jsonrpc-client-* --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
4
.github/workflows/jsonrpc.yml
vendored
4
.github/workflows/jsonrpc.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
build_and_test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
with:
|
||||
node-version: 18.x
|
||||
- name: Add Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
|
||||
- name: npm install
|
||||
working-directory: deltachat-jsonrpc/typescript
|
||||
run: npm install
|
||||
|
||||
12
.github/workflows/nix.yml
vendored
12
.github/workflows/nix.yml
vendored
@@ -21,11 +21,11 @@ jobs:
|
||||
name: check flake formatting
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
- run: nix fmt flake.nix -- --check
|
||||
|
||||
build:
|
||||
@@ -80,11 +80,11 @@ jobs:
|
||||
#- deltachat-rpc-server-x86_64-android
|
||||
#- deltachat-rpc-server-x86-android
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
- run: nix build .#${{ matrix.installable }}
|
||||
|
||||
build-macos:
|
||||
@@ -101,9 +101,9 @@ jobs:
|
||||
# because of <https://github.com/NixOS/nixpkgs/issues/413910>.
|
||||
# - deltachat-rpc-server-aarch64-darwin
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
- run: nix build .#${{ matrix.installable }}
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
working-directory: deltachat-rpc-client
|
||||
run: python3 -m build
|
||||
- name: Store the distribution packages
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: deltachat-rpc-client/dist/
|
||||
@@ -42,9 +42,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download all the dists
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
- name: Publish deltachat-rpc-client to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e
|
||||
|
||||
6
.github/workflows/repl.yml
vendored
6
.github/workflows/repl.yml
vendored
@@ -14,15 +14,15 @@ jobs:
|
||||
name: Build REPL example
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
- name: Build
|
||||
run: nix build .#deltachat-repl-win64
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: repl.exe
|
||||
path: "result/bin/deltachat-repl.exe"
|
||||
|
||||
12
.github/workflows/upload-docs.yml
vendored
12
.github/workflows/upload-docs.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -31,12 +31,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
fetch-depth: 0 # Fetch history to calculate VCS version number.
|
||||
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
- name: Build Python documentation
|
||||
run: nix build .#python-docs
|
||||
- name: Upload to py.delta.chat
|
||||
@@ -50,12 +50,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
fetch-depth: 0 # Fetch history to calculate VCS version number.
|
||||
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
- name: Build C documentation
|
||||
run: nix build .#docs
|
||||
- name: Upload to c.delta.chat
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
working-directory: ./deltachat-jsonrpc/typescript
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
|
||||
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@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
|
||||
21
.github/workflows/zizmor-scan.yml
vendored
21
.github/workflows/zizmor-scan.yml
vendored
@@ -6,26 +6,21 @@ on:
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
zizmor:
|
||||
name: zizmor latest via PyPI
|
||||
name: Run zizmor
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
|
||||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41
|
||||
|
||||
- name: Run zizmor
|
||||
run: uvx zizmor --format sarif . > results.sarif
|
||||
|
||||
- name: Upload SARIF file
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
category: zizmor
|
||||
uses: zizmorcore/zizmor-action@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0
|
||||
|
||||
6
.github/zizmor.yml
vendored
Normal file
6
.github/zizmor.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
rules:
|
||||
unpinned-uses:
|
||||
config:
|
||||
policies:
|
||||
actions/*: ref-pin
|
||||
dependabot/*: ref-pin
|
||||
722
CHANGELOG.md
722
CHANGELOG.md
@@ -1,5 +1,709 @@
|
||||
# Changelog
|
||||
|
||||
## [2.45.0] - 2026-03-14
|
||||
|
||||
### API-Changes
|
||||
|
||||
- JSON-RPC: add `createQrSvg` ([#7949](https://github.com/chatmail/core/pull/7949)).
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Do not read own public key from the database.
|
||||
- Securejoin v3, encrypt all securejoin messages ([#7754](https://github.com/chatmail/core/pull/7754)).
|
||||
- Domain separation between securejoin auth tokens and broadcast channel secrets ([#7981](https://github.com/chatmail/core/pull/7981)).
|
||||
- Merge OpenPGP certificates and distribute relays in them.
|
||||
- Advertise SEIPDv2 feature for new keys.
|
||||
- Don't depend on cleartext `Chat-Version`, `In-Reply-To`, and `References` headers for `prefetch_should_download` ([#7932](https://github.com/chatmail/core/pull/7932)).
|
||||
- Don't send unencrypted `In-Reply-To` and `References` headers ([#7935](https://github.com/chatmail/core/pull/7935)).
|
||||
- Don't send unencrypted `Auto-Submitted` header ([#7938](https://github.com/chatmail/core/pull/7938)).
|
||||
- Remove QR code tokens sync compatibility code.
|
||||
- Mutex to prevent fetching from multiple IMAP servers at the same time.
|
||||
- Add support to gif stickers ([#7941](https://github.com/chatmail/core/pull/7941))
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fix the deadlock by adding a mutex around `wal_checkpoint()`.
|
||||
- Do not run more than one housekeeping at a time.
|
||||
- ffi: don't steal Arc in `dc_jsonrpc_init` ([#7962](https://github.com/chatmail/core/pull/7962)).
|
||||
- Handle the case that the user starts a securejoin, and then deletes the contact ([#7883](https://github.com/chatmail/core/pull/7883)).
|
||||
- Do not trash pre-message if it is received twice.
|
||||
- Set `is_chatmail` during initial configuration.
|
||||
- vCard: Improve property value escaping ([#7931](https://github.com/chatmail/core/pull/7931)).
|
||||
- Percent-decode the address in `dclogin://` URLs.
|
||||
- Make broadcast owner and subscriber hidden contacts for each other ([#7856](https://github.com/chatmail/core/pull/7856)).
|
||||
- Set proper placeholder texts for system messages ([#7953](https://github.com/chatmail/core/pull/7953)).
|
||||
- Add "member added" messages to `OutBroadcast` when executing `SetPgpContacts` sync message ([#7952](https://github.com/chatmail/core/pull/7952)).
|
||||
- Correct channel system messages ([#7959](https://github.com/chatmail/core/pull/7959)).
|
||||
- Drop messages encrypted with the wrong symmetric secret ([#7963](https://github.com/chatmail/core/pull/7963)).
|
||||
- Fix debug assert message incorrectly talking about past members in the current member branch.
|
||||
- Update device chats at the end of configuration.
|
||||
- `deltachat_rpc_client`: make `@futuremethod` decorator keep method metadata.
|
||||
- Use the correct chat description stock string again ([#7939](https://github.com/chatmail/core/pull/7939)).
|
||||
- Use correct string for encryption info.
|
||||
|
||||
### CI
|
||||
|
||||
- Update Rust to 1.94.0.
|
||||
- Allow non-hash references for `actions/*` and `dependabot/*`.
|
||||
- update zizmor workflow to use zizmorcore/zizmor-action.
|
||||
|
||||
### Documentation
|
||||
|
||||
- update `store_self_keypair()` documentation.
|
||||
- Fix documentation for membership change stock strings ([#7944](https://github.com/chatmail/core/pull/7944)).
|
||||
- use correct define for 'description changed' info message.
|
||||
|
||||
### Refactor
|
||||
|
||||
- Un-resultify `KeyPair::new()`.
|
||||
- Remove `KeyPair` type.
|
||||
- pgp: do not use legacy key ID except for IssuerKeyId subpacket.
|
||||
- `use super::*` in qr::dclogin_scheme.
|
||||
- Move WAL checkpointing into `sql::pool` submodule.
|
||||
- Order self addresses by addition timestamp.
|
||||
|
||||
### Tests
|
||||
|
||||
- Remove arbitrary timeouts from `test_4_lowlevel.py`.
|
||||
- Fix flaky `test_qr_securejoin_broadcast` ([#7937](https://github.com/chatmail/core/pull/7937)).
|
||||
- Work around `test_sync_broadcast_and_send_message` flakiness.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- bump version to 2.44.0-dev.
|
||||
- cargo: bump futures from 0.3.31 to 0.3.32.
|
||||
- cargo: bump quick-xml from 0.39.0 to 0.39.2.
|
||||
- cargo: bump criterion from 0.8.1 to 0.8.2.
|
||||
- cargo: bump tempfile from 3.24.0 to 3.25.0.
|
||||
- cargo: bump async-imap from 0.11.1 to 0.11.2.
|
||||
- cargo: bump regex from 1.12.2 to 1.12.3.
|
||||
- cargo: bump hyper-util from 0.1.19 to 0.1.20.
|
||||
- cargo: bump anyhow from 1.0.100 to 1.0.102.
|
||||
- cargo: bump syn from 2.0.114 to 2.0.117.
|
||||
- cargo: bump proptest from 1.9.0 to 1.10.0.
|
||||
- cargo: bump strum from 0.27.2 to 0.28.0.
|
||||
- cargo: bump strum_macros from 0.27.2 to 0.28.0.
|
||||
- cargo: bump quinn-proto from 0.11.9 to 0.11.14.
|
||||
|
||||
## [2.44.0] - 2026-02-27
|
||||
|
||||
### Build system
|
||||
|
||||
- git-cliff: do not capitalize the first letter of commit message.
|
||||
|
||||
### Documentation
|
||||
|
||||
- RELEASE.md: add section about dealing with antivirus false positives.
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- improve logging of connection failures.
|
||||
- add backup versions to the importing error message.
|
||||
- add context to message loading failures.
|
||||
- Add 📱 to all webxdc summaries ([#7790](https://github.com/chatmail/core/pull/7790)).
|
||||
- Send webxdc name instead of raw file name in pre-messages. Display it in summary ([#7790](https://github.com/chatmail/core/pull/7790)).
|
||||
- rpc: add startup health-check and propagate server errors.
|
||||
|
||||
### Fixes
|
||||
|
||||
- imex: do not call `set_config` before running SQL migrations ([#7851](https://github.com/chatmail/core/pull/7851)).
|
||||
- add missing group description strings to cffi.
|
||||
- chat-description-changed text in old clients ([#7870](https://github.com/chatmail/core/pull/7870)).
|
||||
- add cffi type for "Description changed" info message.
|
||||
- If there was no chat description, and it's set to be an empty string, don't send out a "chat description changed" message ([#7879](https://github.com/chatmail/core/pull/7879)).
|
||||
- Make clicking on broadcast member-added messages work always ([#7882](https://github.com/chatmail/core/pull/7882)).
|
||||
- tolerate empty existing directory in Accounts::new() ([#7886](https://github.com/chatmail/core/pull/7886)).
|
||||
- If importing a backup fails, delete the partially-imported profile ([#7885](https://github.com/chatmail/core/pull/7885)).
|
||||
- Don't generate new timestamp for re-sent messages ([#7889](https://github.com/chatmail/core/pull/7889)).
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- cargo: update async-native-tls from 0.5.0 to 0.6.0.
|
||||
- add dev-version bump instructions to RELEASE.md (bumping to 2.44.0-dev).
|
||||
- deps: bump cachix/install-nix-action from 31.9.0 to 31.9.1.
|
||||
|
||||
### Performance
|
||||
|
||||
- batched event reception.
|
||||
|
||||
### Refactor
|
||||
|
||||
- enable clippy::arithmetic_side_effects lint.
|
||||
- imex: check for overflow when adding blob size.
|
||||
- http: saturating addition to calculate cache expiration timestamp.
|
||||
- Move migrations to the end of the file ([#7895](https://github.com/chatmail/core/pull/7895)).
|
||||
- do not chain Autocrypt key verification to parsing.
|
||||
|
||||
### Tests
|
||||
|
||||
- fail fast when CHATMAIL_DOMAIN is unset.
|
||||
|
||||
## [2.43.0] - 2026-02-17
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Group and broadcast channel descriptions ([#7829](https://github.com/chatmail/core/pull/7829)).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Assign iroh gossip topic to pre-message when post-message is received.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Update fast-socks5 to version 1.0.
|
||||
- cargo: Update keccak from 0.1.5 to 0.1.6.
|
||||
- deps: Bump astral-sh/setup-uv from 7.1.6 to 7.3.0.
|
||||
|
||||
### Performance
|
||||
|
||||
- Use recv_direct() instead of recv() on the event channel.
|
||||
|
||||
### Refactor
|
||||
|
||||
- Enable `clippy::manual_is_variant_and`.
|
||||
|
||||
### Tests
|
||||
|
||||
- Fix flaky `test_transport_synchronization` ([#7850](https://github.com/chatmail/core/pull/7850)).
|
||||
|
||||
## [2.42.0] - 2026-02-10
|
||||
|
||||
### Fixes
|
||||
|
||||
- Set `mvbox_move` to '0' explicitly for existing chatmail profiles.
|
||||
It's needed to prevent device message about deprecated `mvbox_move` option from appearing in chatmail profiles.
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Do not scan not watched folders.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Update rPGP from 0.18.0 to 0.19.0.
|
||||
- cargo: Bump quick-xml from 0.38.4 to 0.39.0.
|
||||
|
||||
### Tests
|
||||
|
||||
- Remove test_dont_show_emails.
|
||||
|
||||
### Other
|
||||
|
||||
- Fix typo in CHANGELOG for marknoticed_all_chats.
|
||||
|
||||
## [2.41.0] - 2026-02-06
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Do not require `ShowEmails` to be set to `All` for adding second relay.
|
||||
- Use different strings for audio and video calls.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Don't set download state to Failure if message is available on another Session's transport ([#7684](https://github.com/chatmail/core/pull/7684)).
|
||||
- Make use of call stock strings.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- cargo: Bump `time` from 0.3.37 to 0.3.47.
|
||||
|
||||
## [2.40.0] - 2026-02-04
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Receive_imf: Log reasoning for chat assignment.
|
||||
- Use more fitting encryption info message.
|
||||
- Send Intended Recipient Fingerprint subpackets.
|
||||
- Trash messages with intended recipient fingerprints, but w/o our one included.
|
||||
- Do not collect email addresses from messages after configuration.
|
||||
- Add device message about legacy `mvbox_move`.
|
||||
- Never create IMAP folders.
|
||||
- Make summary for pre-messages look like summary for fully downloaded messages ([#7775](https://github.com/chatmail/core/pull/7775)).
|
||||
- Don't call `BlobObject::create_and_deduplicate()` when forwarding message to the same account.
|
||||
- Allow clients to specify whether a call has video initially or not ([#7740](https://github.com/chatmail/core/pull/7740)).
|
||||
- Do not load more than one own key from the keychain.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Cross-account forwarding of a message which `has_html()` ([#7791](https://github.com/chatmail/core/pull/7791)).
|
||||
- Make self-contact a key-contact even if key isn't generated yet.
|
||||
- `apply_group_changes()`: Check whether From is key-contact.
|
||||
- Don't add SELF to unencrypted chat created from encrypted message ([#7661](https://github.com/chatmail/core/pull/7661)).
|
||||
- Don't upscale images and test that image resolution isn't changed unnecessarily ([#7769](https://github.com/chatmail/core/pull/7769)).
|
||||
- Restart i/o when there are new transports in a sync message ([#7640](https://github.com/chatmail/core/pull/7640)).
|
||||
- `add_or_lookup_key_contacts*()`: Advance fingerprint_iter on invalid address.
|
||||
- `receive_imf`: Look up key contact by intended recipient fingerprint ([#7661](https://github.com/chatmail/core/pull/7661)).
|
||||
- Remove `Config::DeleteToTrash` and `Config::ConfiguredTrashFolder`.
|
||||
|
||||
### API-Changes
|
||||
|
||||
- jsonrpc(python): Process events forever by default.
|
||||
|
||||
### CI
|
||||
|
||||
- Make scripts/deny.sh test the locked version of dependencies.
|
||||
|
||||
### Refactor
|
||||
|
||||
- Remove unneeded dbg! statements ([#7776](https://github.com/chatmail/core/pull/7776)).
|
||||
- Remove unused Context.is_inbox().
|
||||
- Rename lookup_key_contacts_by_address_list() to lookup_key_contacts_fallback_to_chat().
|
||||
- Mark `ProviderOptions` as `non_exhaustive`.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Update provider database.
|
||||
- cargo: Update `bytes` from 1.11.0 to 1.11.1.
|
||||
- cargo: Bump tokio from 1.48.0 to 1.49.0.
|
||||
- cargo: Bump tokio-util from 0.7.17 to 0.7.18.
|
||||
- cargo: Bump libc from 0.2.178 to 0.2.180.
|
||||
- cargo: Bump quote from 1.0.42 to 1.0.44.
|
||||
- cargo: Bump syn from 2.0.111 to 2.0.114.
|
||||
- cargo: Bump human-panic from 2.0.4 to 2.0.6.
|
||||
- cargo: Bump chrono from 0.4.42 to 0.4.43.
|
||||
- cargo: Bump data-encoding from 2.9.0 to 2.10.0.
|
||||
- cargo: Bump colorutils-rs from 0.7.5 to 0.7.6.
|
||||
- Update provider database.
|
||||
- cargo: Bump thiserror from 2.0.17 to 2.0.18.
|
||||
- deps: Bump EmbarkStudios/cargo-deny-action from 2.0.14 to 2.0.15.
|
||||
- Remove RUSTSEC-2026-0002 exception from deny.toml.
|
||||
- cargo: Bump tokio-stream from 0.1.17 to 0.1.18.
|
||||
- cargo: Bump toml from 0.9.10+spec-1.1.0 to 0.9.11+spec-1.1.0.
|
||||
- cargo: Bump serde_json from 1.0.148 to 1.0.149.
|
||||
- cargo: Bump uuid from 1.19.0 to 1.20.0.
|
||||
- cargo: Bump rustls-pki-types from 1.13.2 to 1.14.0.
|
||||
- cargo: Bump tracing-subscriber from 0.3.20 to 0.3.22.
|
||||
|
||||
### Tests
|
||||
|
||||
- 2nd device receives message via new primary transport.
|
||||
- Make `test_dont_move_sync_msgs` less flaky.
|
||||
- Encrypted incoming message goes to encrypted 1:1 chat even if references messages in ad-hoc group.
|
||||
- Message in blocked chat arrives as InSeen.
|
||||
- Set `mvbox_move` to 0 for test rust accounts.
|
||||
|
||||
## [2.39.0] - 2026-01-23
|
||||
|
||||
### CI
|
||||
|
||||
- Update Rust to 1.93.0.
|
||||
|
||||
### Documentation
|
||||
|
||||
- RELEASE.md: Push preparation commit to the main branch before tagging.
|
||||
- RELEASE.md: Add section about dealing with failed releases.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Forward message with file ([#7755](https://github.com/chatmail/core/pull/7755)).
|
||||
- Do not additionally reduce the resolution of images that fit into the resolution-limit and are larger than the file-size-limit ([#7760](https://github.com/chatmail/core/pull/7760)).
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Merge v2.38.0 into main branch.
|
||||
- Cleanup deprecated functions/defines ([#7763](https://github.com/chatmail/core/pull/7763)).
|
||||
|
||||
## [2.38.0] - 2026-01-22
|
||||
|
||||
### API-Changes
|
||||
|
||||
- [**breaking**] Jsonrpc: remove `contacts` from `FullChat`. To migrate load contacts on demand via `get_contacts_by_ids` using `FullChat.contactIds` ([#7282](https://github.com/chatmail/core/pull/7282)).
|
||||
- jsonrpc: Add run_until parameter for bots ([#7688](https://github.com/chatmail/core/pull/7688)).
|
||||
- rust, jsonrpc: Add `get_message_read_receipt_count` method ([#7732](https://github.com/chatmail/core/pull/7732)).
|
||||
- rust and jsonrpc: Marknoticed_all_chats method to mark all chats as noticed, including muted ones. ([#7709](https://github.com/chatmail/core/pull/7709)).
|
||||
- Public re-export of Connectivity ([#7737](https://github.com/chatmail/core/pull/7737)).
|
||||
|
||||
### Documentation
|
||||
|
||||
- Fix chat types.
|
||||
- Set_config_from_qr() configures context for "DCACCOUNT:" and "DCLOGIN:" QRs ([#7450](https://github.com/chatmail/core/pull/7450)).
|
||||
- Fix formatting of `indoc!` link.
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Pre-messages / next version of download on demand ([#7371](https://github.com/chatmail/core/pull/7371)).
|
||||
- Connectivity view: move quota up and combine with IMAP state. ([#7653](https://github.com/chatmail/core/pull/7653)).
|
||||
- Execute sync message before checking for primary transport update.
|
||||
- Disable partial search by contact address.
|
||||
- Don't put text into post-message ([#7714](https://github.com/chatmail/core/pull/7714)).
|
||||
- Don't scale up Origin of multiple and broadcast recipients when sending a message.
|
||||
- pgp: Use preferred hash algorithm for signing instead of hardcoded SHA256.
|
||||
- In teamprofiles, don't mark chat as read on outgoing message ([#7717](https://github.com/chatmail/core/pull/7717)).
|
||||
- Send and apply MDNs to self ([#7005](https://github.com/chatmail/core/pull/7005))
|
||||
|
||||
### Fixes
|
||||
|
||||
- Do not show contact address in message info ([#7695](https://github.com/chatmail/core/pull/7695)).
|
||||
- Take transport_id into account when marking messages with \Seen flags.
|
||||
- Send bcc-self messages to all own relays ([#7656](https://github.com/chatmail/core/pull/7656)).
|
||||
- Only emit TransportsModified if transports are really modified.
|
||||
- Logging errors in deltachat-rpc-server during startup ([#7707](https://github.com/chatmail/core/pull/7707)).
|
||||
- Use only lowercase letters for stats id ([#7700](https://github.com/chatmail/core/pull/7700)).
|
||||
- Hide incoming broadcasts in `DC_GCL_FOR_FORWARDING` ([#7726](https://github.com/chatmail/core/pull/7726)).
|
||||
- Do not resolve ICE server hostnames during IMAP loop.
|
||||
- More reliable parsing of `dclogin:` links with ip address as host ([#7734](https://github.com/chatmail/core/pull/7734)).
|
||||
- Don't remember old channel members in the database ([#7716](https://github.com/chatmail/core/pull/7716)).
|
||||
- Make it possible to leave and immediately delete a chat ([#7744](https://github.com/chatmail/core/pull/7744)).
|
||||
- Emit MsgsChanged instead of MsgsNoticed on self-MDN if chat still has fresh messages.
|
||||
- Prevent possible infinite loop with invalid `smtp` row ([#7746](https://github.com/chatmail/core/pull/7746)).
|
||||
- Sync broadcast subscribers list ([#7578](https://github.com/chatmail/core/pull/7578))
|
||||
|
||||
### Refactor
|
||||
|
||||
- Don't use `concat!` in sql statements ([#7720](https://github.com/chatmail/core/pull/7720)).
|
||||
|
||||
### Tests
|
||||
|
||||
- Port test_dont_move_sync_msgs to JSON-RPC ([#7676](https://github.com/chatmail/core/pull/7676)).
|
||||
- rpc-client: Replace remaining print()s with `logging` ([#6082](https://github.com/chatmail/core/pull/6082)).
|
||||
|
||||
## [2.37.0] - 2026-01-08
|
||||
|
||||
### API-Changes
|
||||
|
||||
- JSON-RPC API `get_all_ui_config_keys` to get all "ui.*" config keys ([#7579](https://github.com/chatmail/core/pull/7579)).
|
||||
- Add `who_can_call_me` config option.
|
||||
- cffi api to create account manager with existing events channel to see events emitted during startup. `dc_event_channel_new`, `dc_event_channel_unref`, `dc_event_channel_get_event_emitter` and `dc_accounts_new_with_event_channel` ([#7609](https://github.com/chatmail/core/pull/7609)).
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Config option to skip seen synchronization ([#7694](https://github.com/chatmail/core/pull/7694)).
|
||||
- More text instead of sender in channel summary.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Do not rely on Secure-Join header to detect {vc,vg}-request.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Update instructions to UI where to display the address.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- cargo: bump rsa from 0.9.9 to 0.9.10.
|
||||
- Update lru 0.12.3 to 0.12.5 and add RUSTSEC-2026-0002 exception.
|
||||
|
||||
### Refactor
|
||||
|
||||
- ffi: Replace implicit drop in cffi with explicit `drop(Arc::from_raw(var))` ([#7664](https://github.com/chatmail/core/pull/7664)).
|
||||
|
||||
### Tests
|
||||
|
||||
- Regression test for vc-request encrypted by the server.
|
||||
- Test that channel summary does not have sender name.
|
||||
|
||||
## [2.36.0] - 2026-01-03
|
||||
|
||||
### CI
|
||||
|
||||
- Pin GitHub Action references.
|
||||
|
||||
### API-Changes
|
||||
|
||||
- Add transports event to FFI.
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Add core version to `receive_imf` failure message.
|
||||
- Connectivity view: quota for all transports ([#7630](https://github.com/chatmail/core/pull/7630)).
|
||||
- Send sync messages over SMTP and do not move them to mvbox.
|
||||
|
||||
### Fixes
|
||||
|
||||
- When accepting group, add members with `Origin::IncomingTo` and sort them down in the contact list (7592).
|
||||
- Update fallback welcome message.
|
||||
- `inner_configure`: Check Config::OnlyFetchMvbox before MvboxMove for multi-transport ([#7637](https://github.com/chatmail/core/pull/7637)).
|
||||
- Reset options not available for chatmail on chatmail profiles.
|
||||
- Don't send webxdc notification for `notify: "*"` when chat is muted ([#7658](https://github.com/chatmail/core/pull/7658)).
|
||||
|
||||
### Documentation
|
||||
|
||||
- `delete_chat()`: don't lie that messages aren't deleted from server.
|
||||
- Remove references to removed `sentbox_watch` config.
|
||||
- Update documentation for `TransportsModified` event.
|
||||
|
||||
### Tests
|
||||
|
||||
- Contact list after accepting group with unknown contacts ([#7592](https://github.com/chatmail/core/pull/7592)).
|
||||
- Port test_import_export_online_all to JSON-RPC ([#7411](https://github.com/chatmail/core/pull/7411)).
|
||||
|
||||
### Refactor
|
||||
|
||||
- Turn `DC_VERSION_STR` into `&str`.
|
||||
- ffi: Remove one pointer indirection for `dc_accounts_t`.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- deps: Bump actions/download-artifact from 6 to 7.
|
||||
- deps: Bump actions/upload-artifact from 5 to 6.
|
||||
- deps: Bump astral-sh/setup-uv from 7.1.4 to 7.1.6.
|
||||
- deps: Bump cachix/install-nix-action from 31.8.4 to 31.9.0.
|
||||
- cargo: Bump serde_json from 1.0.145 to 1.0.147.
|
||||
- cargo: Bump uuid from 1.18.1 to 1.19.0.
|
||||
- cargo: Bump toml from 0.9.8 to 0.9.10+spec-1.1.0.
|
||||
- cargo: Bump tempfile from 3.23.0 to 3.24.0.
|
||||
- cargo: Bump libc from 0.2.177 to 0.2.178.
|
||||
- cargo: Bump tracing from 0.1.41 to 0.1.44.
|
||||
- cargo: Bump hyper-util from 0.1.18 to 0.1.19.
|
||||
- cargo: Bump log from 0.4.28 to 0.4.29.
|
||||
- cargo: Bump rustls-pki-types from 1.13.0 to 1.13.2.
|
||||
- cargo: Bump criterion from 0.7.0 to 0.8.1.
|
||||
|
||||
## [2.35.0] - 2025-12-16
|
||||
|
||||
### API-Changes
|
||||
|
||||
- Add blob dir size to storage info ([#7605](https://github.com/chatmail/core/pull/7605)).
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Use `turn.delta.chat` as fallback TURN server ([#7382](https://github.com/chatmail/core/pull/7382)).
|
||||
- Add ip addresses of known public chatmail relays from https://chatmail.at/relays to DNS cache ([#7607](https://github.com/chatmail/core/pull/7607)).
|
||||
- Improve error messages on adding relays.
|
||||
- Add transport addresses to IMAP URLs in message info.
|
||||
- `lookup_host_with_cache()`: Don't return empty address list ([#7596](https://github.com/chatmail/core/pull/7596)).
|
||||
|
||||
### Fixes
|
||||
|
||||
- `get_chat_msgs_ex()`: Don't match on "S=" (Cmd) in param payload.
|
||||
- Remove `SecurejoinWait` info message when received Alice's key ([#7585](https://github.com/chatmail/core/pull/7585)).
|
||||
- Do not set normalized name for existing chats and contacts in a migration.
|
||||
- Remove now redundant "used_account_settings" and "entered_account_settings" from `Context.get_info()` ([#7587](https://github.com/chatmail/core/pull/7587)).
|
||||
- Don't use fallback servers if got TURN servers from IMAP METADATA.
|
||||
- Use fallback ICE servers if server can't IMAP METADATA ([#7382](https://github.com/chatmail/core/pull/7382)).
|
||||
- Add explicit limit for adding relays (5 at the moment) ([#7611](https://github.com/chatmail/core/pull/7611)).
|
||||
- Take `transport_id` into account when using `imap` table.
|
||||
|
||||
### CI
|
||||
|
||||
- Update Rust to 1.92.0.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Apply Rust 1.92.0 clippy suggestions.
|
||||
|
||||
### Other
|
||||
|
||||
- Log entered login params and actual used params on configuration failure ([#7610](https://github.com/chatmail/core/pull/7610)).
|
||||
|
||||
## [2.34.0] - 2025-12-11
|
||||
|
||||
### API-Changes
|
||||
|
||||
- rpc-client: Accept `Account` for `Chat.{add,remove}_contact()`.
|
||||
- rpc-client: Add `Chat.num_contacts()`.
|
||||
- Forwarding messages to another profile ([#7491](https://github.com/chatmail/core/pull/7491)).
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Double ringing time to 120 seconds.
|
||||
- Better logging for failing securejoin messages ([#7593](https://github.com/chatmail/core/pull/7593)).
|
||||
- Add multi-transport information to `Context.get_info` ([#7583](https://github.com/chatmail/core/pull/7583))
|
||||
|
||||
### Fixes
|
||||
|
||||
- Multi-transport: all transports were shown as "inbox" in connectivity view, now they are shown by their hostname ([#7582](https://github.com/chatmail/core/pull/7582)).
|
||||
- Multi-transport: Synchronize primary transport immediately after changing it.
|
||||
- Use u64 instead of usize to calculate storage usage.
|
||||
- Use u64 to represent the number of bytes in backup files.
|
||||
- Use u64 to count the number of bytes sent/received over the network.
|
||||
- Use logging macros instead of emitting event directly, so that it is also logged by tracing ([#7459](https://github.com/chatmail/core/pull/7459)).
|
||||
- Let securejoin succeed even if the chat was deleted in the meantime ([#7594](https://github.com/chatmail/core/pull/7594)).
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Add RUSTSEC-2025-0134 exception to deny.toml.
|
||||
|
||||
### Refactor
|
||||
|
||||
- Use u16 instead of usize to represent progress bar.
|
||||
- Remove EncryptHelper.prefer_encrypt.
|
||||
- Add params when forwarding message instead of removing unneeded ones.
|
||||
|
||||
### Tests
|
||||
|
||||
- Port test_synchronize_member_list_on_group_rejoin to JSON-RPC.
|
||||
- Test setting up second device between core versions.
|
||||
|
||||
## [2.33.0] - 2025-12-05
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Case-insensitive search for non-ASCII chat and contact names ([#7477](https://github.com/chatmail/core/pull/7477)).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Recognize all transport addresses as own addresses.
|
||||
|
||||
## [2.32.0] - 2025-12-04
|
||||
|
||||
Version bump to trigger publishing of npm prebuilds
|
||||
that failed to be published for 2.31.0 due to not configured "trusted publishers".
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- `lookup_or_create_adhoc_group()`: Add context to SQL errors ([#7554](https://github.com/chatmail/core/pull/7554)).
|
||||
|
||||
## [2.31.0] - 2025-12-04
|
||||
|
||||
### CI
|
||||
|
||||
- Update npm before publishing packages.
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Use v2 SEIPD when sending messages to self.
|
||||
|
||||
## [2.30.0] - 2025-12-04
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Disable SNI for STARTTLS ([#7499](https://github.com/chatmail/core/pull/7499)).
|
||||
- Introduce cross-core testing along with improvements to test frameworking.
|
||||
- Synchronize transports via sync messages.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fix shutdown shortly after call.
|
||||
|
||||
### API-Changes
|
||||
|
||||
- Add `TransportsModified` event (for tests).
|
||||
|
||||
### CI
|
||||
|
||||
- Use "trusted publishing" for NPM packages.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- deps: Bump actions/checkout from 5 to 6.
|
||||
- cargo: Bump syn from 2.0.110 to 2.0.111.
|
||||
- deps: Bump astral-sh/setup-uv from 7.1.3 to 7.1.4.
|
||||
- cargo: Bump sdp from 0.8.0 to 0.10.0.
|
||||
- Remove two outdated todo comments ([#7550](https://github.com/chatmail/core/pull/7550)).
|
||||
|
||||
## [2.29.0] - 2025-12-01
|
||||
|
||||
### API-Changes
|
||||
|
||||
- deltachat-rpc-client: Add Message.exists().
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- [**breaking**] Increase backup version from 3 to 4.
|
||||
- Hide `To` header in encrypted messages.
|
||||
- `deltachat_rpc_client.Rpc` accepts `rpc_server_path` for using a particular deltachat-rpc-server ([#7493](https://github.com/chatmail/core/pull/7493)).
|
||||
- Don't send `Chat-Group-Avatar` header in unencrypted groups.
|
||||
- Don't update `self-{avatar,status}` from received messages ([#7002](https://github.com/chatmail/core/pull/7002)).
|
||||
|
||||
### Fixes
|
||||
|
||||
- `CREATE INDEX imap_only_rfc724_mid ON imap(rfc724_mid)` ([#7490](https://github.com/chatmail/core/pull/7490)).
|
||||
- Use the same webxdc ratelimit for all email servers.
|
||||
- Handle the case when account does not exist in `get_existing_msg_ids()`.
|
||||
- Don't send self-avatar in unencrypted messages ([#7136](https://github.com/chatmail/core/pull/7136)).
|
||||
- Do not configure folders during transport configuration.
|
||||
- Upload sync messages only with the primary transport.
|
||||
- Do not use deprecated ConfiguredProvider in get_configured_provider.
|
||||
|
||||
### Build system
|
||||
|
||||
- Make scripts for remote testing usable.
|
||||
- Increase minimum supported Python version to 3.10.
|
||||
- Use SPDX license expression in Python package metadata.
|
||||
|
||||
### CI
|
||||
|
||||
- Set timeout-minutes for all jobs in ci.yaml workflow.
|
||||
- Do not install Python manually to bulid RPC server wheels.
|
||||
- Do not build fake RPC server source packages.
|
||||
- Build Python wheels in separate jobs.
|
||||
|
||||
### Refactor
|
||||
|
||||
- [**breaking**] Remove some unneeded stock strings ([#7496](https://github.com/chatmail/core/pull/7496)).
|
||||
- Strike events in rpc-client request handling, get result from queue.
|
||||
- Use ConfiguredProvider config directly when loading legacy settings.
|
||||
- Remove update_icons and disable_server_delete migrations.
|
||||
- Use `SYMMETRIC_KEY_ALGORITHM` constant in `symm_encrypt_message()`.
|
||||
- Make signing key non-optional for `pk_encrypt`.
|
||||
|
||||
### Tests
|
||||
|
||||
- `test_remove_member_bcc`: Test unencrypted group as it was initially.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- deps: Bump cachix/install-nix-action from 31.8.1 to 31.8.4.
|
||||
- cargo: Bump hyper from 1.7.0 to 1.8.1.
|
||||
- cargo: Bump human-panic from 2.0.3 to 2.0.4.
|
||||
- cargo: Bump hyper-util from 0.1.17 to 0.1.18.
|
||||
- cargo: Bump rusqlite from 0.36.0 to 0.37.0.
|
||||
- cargo: Bump tokio-util from 0.7.16 to 0.7.17.
|
||||
- cargo: Bump toml from 0.9.7 to 0.9.8.
|
||||
- cargo: Bump proptest from 1.8.0 to 1.9.0.
|
||||
- cargo: Bump parking_lot from 0.12.4 to 0.12.5.
|
||||
- cargo: Bump syn from 2.0.106 to 2.0.110.
|
||||
- cargo: Bump quick-xml from 0.38.3 to 0.38.4.
|
||||
- cargo: Bump rustls-pki-types from 1.12.0 to 1.13.0.
|
||||
- cargo: Bump nu-ansi-term from 0.50.1 to 0.50.3.
|
||||
- cargo: Bump sanitize-filename from 0.5.0 to 0.6.0.
|
||||
- cargo: Bump quote from 1.0.41 to 1.0.42.
|
||||
- cargo: Bump libc from 0.2.176 to 0.2.177.
|
||||
- cargo: Bump bytes from 1.10.1 to 1.11.0.
|
||||
- cargo: Bump image from 0.25.8 to 0.25.9.
|
||||
- cargo: Bump rand from 0.9.0 to 0.9.2 ([#7501](https://github.com/chatmail/core/pull/7501)).
|
||||
- cargo: Bump tokio from 1.45.1 to 1.48.0.
|
||||
|
||||
## [2.28.0] - 2025-11-23
|
||||
|
||||
### API-Changes
|
||||
|
||||
- New API `get_existing_msg_ids()` to check if the messages with given IDs exist.
|
||||
- Add API to get storage usage information. (JSON-RPC method: `get_storage_usage_report_string`) ([#7486](https://github.com/chatmail/core/pull/7486)).
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Experimentaly allow adding second transport.
|
||||
There is no synchronization yet, so UIs should not allow the user to change the address manually and only expose the ability to add transports if `bcc_self` is disabled.
|
||||
- Default `bcc_self` to 0 for all new accounts.
|
||||
- Rephrase "Establishing end-to-end encryption" -> "Establishing connection".
|
||||
- Stock string for joining a channel ([#7480](https://github.com/chatmail/core/pull/7480)).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Limit the range of `Date` to up to 6 days in the past.
|
||||
- `ContactId::set_name_ex()`: Emit ContactsChanged when transaction is completed.
|
||||
- Set SQLite busy timeout to 1 minute on iOS.
|
||||
- Sort system messages to the bottom of the chat.
|
||||
- Assign outgoing self-sent unencrypted messages to ad-hoc groups with only SELF ([#7409](https://github.com/chatmail/core/pull/7409)).
|
||||
- Add missing stock strings.
|
||||
- Look up or create ad-hoc group if there are duplicate addresses in "To".
|
||||
|
||||
### Documentation
|
||||
|
||||
- Add missing RFC 9788, link 'Header Protection for Cryptographically Protected Email' as other RFC.
|
||||
- Remove unsupported RFC 3503 (`$MDNSent` flag) from the list of standards.
|
||||
- Mark database encryption support as deprecated ([#7403](https://github.com/chatmail/core/pull/7403)).
|
||||
|
||||
### Build system
|
||||
|
||||
- Increase Minimum Supported Rust Version to 1.88.0.
|
||||
- Update rPGP from 0.17.0 to 0.18.0.
|
||||
- nix: Update `fenix` and use it for all Rust builds.
|
||||
|
||||
### CI
|
||||
|
||||
- Do not use --encoding option for rst-lint.
|
||||
|
||||
### Refactor
|
||||
|
||||
- Use `HashMap::extract_if()` stabilized in Rust 1.88.0.
|
||||
- Remove some easy to remove unwrap() calls.
|
||||
|
||||
### Tests
|
||||
|
||||
- Contact shalln't be verified by another having unknown verifier.
|
||||
|
||||
## [2.27.0] - 2025-11-16
|
||||
|
||||
### API-Changes
|
||||
@@ -7185,3 +7889,21 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
|
||||
[2.25.0]: https://github.com/chatmail/core/compare/v2.24.0..v2.25.0
|
||||
[2.26.0]: https://github.com/chatmail/core/compare/v2.25.0..v2.26.0
|
||||
[2.27.0]: https://github.com/chatmail/core/compare/v2.26.0..v2.27.0
|
||||
[2.28.0]: https://github.com/chatmail/core/compare/v2.27.0..v2.28.0
|
||||
[2.29.0]: https://github.com/chatmail/core/compare/v2.28.0..v2.29.0
|
||||
[2.30.0]: https://github.com/chatmail/core/compare/v2.29.0..v2.30.0
|
||||
[2.31.0]: https://github.com/chatmail/core/compare/v2.30.0..v2.31.0
|
||||
[2.32.0]: https://github.com/chatmail/core/compare/v2.31.0..v2.32.0
|
||||
[2.33.0]: https://github.com/chatmail/core/compare/v2.32.0..v2.33.0
|
||||
[2.34.0]: https://github.com/chatmail/core/compare/v2.33.0..v2.34.0
|
||||
[2.35.0]: https://github.com/chatmail/core/compare/v2.34.0..v2.35.0
|
||||
[2.36.0]: https://github.com/chatmail/core/compare/v2.35.0..v2.36.0
|
||||
[2.37.0]: https://github.com/chatmail/core/compare/v2.36.0..v2.37.0
|
||||
[2.38.0]: https://github.com/chatmail/core/compare/v2.37.0..v2.38.0
|
||||
[2.39.0]: https://github.com/chatmail/core/compare/v2.38.0..v2.39.0
|
||||
[2.40.0]: https://github.com/chatmail/core/compare/v2.39.0..v2.40.0
|
||||
[2.41.0]: https://github.com/chatmail/core/compare/v2.40.0..v2.41.0
|
||||
[2.42.0]: https://github.com/chatmail/core/compare/v2.41.0..v2.42.0
|
||||
[2.43.0]: https://github.com/chatmail/core/compare/v2.42.0..v2.43.0
|
||||
[2.44.0]: https://github.com/chatmail/core/compare/v2.43.0..v2.44.0
|
||||
[2.45.0]: https://github.com/chatmail/core/compare/v2.44.0..v2.45.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Contributing to Delta Chat
|
||||
# Contributing to chatmail core
|
||||
|
||||
## Bug reports
|
||||
|
||||
|
||||
790
Cargo.lock
generated
790
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
35
Cargo.toml
35
Cargo.toml
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
name = "deltachat"
|
||||
version = "2.27.0"
|
||||
version = "2.46.0-dev"
|
||||
edition = "2024"
|
||||
license = "MPL-2.0"
|
||||
rust-version = "1.85"
|
||||
rust-version = "1.88"
|
||||
repository = "https://github.com/chatmail/core"
|
||||
|
||||
[profile.dev]
|
||||
@@ -45,7 +45,7 @@ anyhow = { workspace = true }
|
||||
async-broadcast = "0.7.2"
|
||||
async-channel = { workspace = true }
|
||||
async-imap = { version = "0.11.1", default-features = false, features = ["runtime-tokio", "compress"] }
|
||||
async-native-tls = { version = "0.5", default-features = false, features = ["runtime-tokio"] }
|
||||
async-native-tls = { version = "0.6", default-features = false, features = ["runtime-tokio"] }
|
||||
async-smtp = { version = "0.10.2", default-features = false, features = ["runtime-tokio"] }
|
||||
async_zip = { version = "0.0.18", default-features = false, features = ["deflate", "tokio-fs"] }
|
||||
base64 = { workspace = true }
|
||||
@@ -56,7 +56,7 @@ 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"
|
||||
fast-socks5 = "1"
|
||||
fd-lock = "4"
|
||||
futures-lite = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
@@ -78,17 +78,17 @@ num-derive = "0.4"
|
||||
num-traits = { workspace = true }
|
||||
parking_lot = "0.12.4"
|
||||
percent-encoding = "2.3"
|
||||
pgp = { version = "0.17.0", default-features = false }
|
||||
pgp = { version = "0.19.0", default-features = false }
|
||||
pin-project = "1"
|
||||
qrcodegen = "1.7.0"
|
||||
quick-xml = { version = "0.38", features = ["escape-html"] }
|
||||
quick-xml = { version = "0.39", features = ["escape-html"] }
|
||||
rand-old = { package = "rand", version = "0.8" }
|
||||
rand = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
rusqlite = { workspace = true, features = ["sqlcipher"] }
|
||||
rustls-pki-types = "1.12.0"
|
||||
sanitize-filename = { workspace = true }
|
||||
sdp = "0.8.0"
|
||||
sdp = "0.10.0"
|
||||
serde_json = { workspace = true }
|
||||
serde_urlencoded = "0.7.1"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
@@ -96,8 +96,8 @@ sha-1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
shadowsocks = { version = "1.23.1", default-features = false, features = ["aead-cipher", "aead-cipher-2022"] }
|
||||
smallvec = "1.15.1"
|
||||
strum = "0.27"
|
||||
strum_macros = "0.27"
|
||||
strum = "0.28"
|
||||
strum_macros = "0.28"
|
||||
tagger = "4.3.4"
|
||||
textwrap = "0.16.2"
|
||||
thiserror = { workspace = true }
|
||||
@@ -111,11 +111,12 @@ toml = "0.9"
|
||||
tracing = "0.1.41"
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["serde", "v4"] }
|
||||
walkdir = "2.5.0"
|
||||
webpki-roots = "0.26.8"
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = { workspace = true, features = ["backtrace"] } # Enable `backtrace` feature in tests.
|
||||
criterion = { version = "0.7.0", features = ["async_tokio"] }
|
||||
criterion = { version = "0.8.1", features = ["async_tokio"] }
|
||||
futures-lite = { workspace = true }
|
||||
log = { workspace = true }
|
||||
nu-ansi-term = { workspace = true }
|
||||
@@ -181,11 +182,11 @@ harness = false
|
||||
anyhow = "1"
|
||||
async-channel = "2.5.0"
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4.42", default-features = false }
|
||||
chrono = { version = "0.4.43", default-features = false }
|
||||
deltachat-contact-tools = { path = "deltachat-contact-tools" }
|
||||
deltachat-jsonrpc = { path = "deltachat-jsonrpc", default-features = false }
|
||||
deltachat = { path = ".", default-features = false }
|
||||
futures = "0.3.31"
|
||||
futures = "0.3.32"
|
||||
futures-lite = "2.6.1"
|
||||
libc = "0.2"
|
||||
log = "0.4"
|
||||
@@ -193,15 +194,15 @@ mailparse = "0.16.1"
|
||||
nu-ansi-term = "0.50"
|
||||
num-traits = "0.2"
|
||||
rand = "0.9"
|
||||
regex = "1.10"
|
||||
rusqlite = "0.36"
|
||||
sanitize-filename = "0.5"
|
||||
regex = "1.12"
|
||||
rusqlite = "0.37"
|
||||
sanitize-filename = "0.6"
|
||||
serde = "1.0"
|
||||
serde_json = "1"
|
||||
tempfile = "3.23.0"
|
||||
tempfile = "3.25.0"
|
||||
thiserror = "2"
|
||||
tokio = "1"
|
||||
tokio-util = "0.7.16"
|
||||
tokio-util = "0.7.18"
|
||||
tracing-subscriber = "0.3"
|
||||
yerpc = "0.6.4"
|
||||
|
||||
|
||||
55
RELEASE.md
55
RELEASE.md
@@ -1,4 +1,4 @@
|
||||
# Releasing a new version of DeltaChat core
|
||||
# Releasing a new version of chatmail core
|
||||
|
||||
For example, to release version 1.116.0 of the core, do the following steps.
|
||||
|
||||
@@ -14,8 +14,55 @@ For example, to release version 1.116.0 of the core, do the following steps.
|
||||
5. Commit the changes as `chore(release): prepare for 1.116.0`.
|
||||
Optionally, use a separate branch like `prep-1.116.0` for this commit and open a PR for review.
|
||||
|
||||
6. Tag the release: `git tag --annotate v1.116.0`.
|
||||
6. Push the commit to the `main` branch.
|
||||
|
||||
7. Push the release tag: `git push origin v1.116.0`.
|
||||
7. Once the commit is on the `main` branch and passed CI, tag the release: `git tag --annotate v1.116.0`.
|
||||
|
||||
8. Create a GitHub release: `gh release create v1.116.0 --notes ''`.
|
||||
8. Push the release tag: `git push origin v1.116.0`.
|
||||
|
||||
9. Create a GitHub release: `gh release create v1.116.0 --notes ''`.
|
||||
|
||||
10. Update the version to the next development version:
|
||||
`scripts/set_core_version.py 1.117.0-dev`.
|
||||
|
||||
11. Commit and push the change:
|
||||
`git commit -m "chore: bump version to 1.117.0-dev" && git push origin main`.
|
||||
|
||||
12. Once the binaries are generated and [published](https://github.com/chatmail/core/releases),
|
||||
check Windows binaries for false positive detections at [VirusTotal].
|
||||
Either upload the binaries directly or submit a direct link to the artifact.
|
||||
You can use [old browsers interface](https://www.virustotal.com/old-browsers/)
|
||||
if there are problems with using the default website.
|
||||
If you submit a direct link and get to the page saying
|
||||
"No security vendors flagged this URL as malicious",
|
||||
it does not mean that the file itself is not detected.
|
||||
You need to go to the "details" tab
|
||||
and click on the SHA-256 hash in the "Body SHA-256" section.
|
||||
If any false positive is detected,
|
||||
open an issue to track removing it.
|
||||
See <https://github.com/chatmail/core/issues/7847>
|
||||
for an example of false positive detection issue.
|
||||
If there is a false positive "Microsoft" detection,
|
||||
mark the issue as a blocker.
|
||||
|
||||
[VirusTotal]: https://www.virustotal.com/
|
||||
|
||||
## Dealing with antivirus false positives
|
||||
|
||||
If Windows release is incorrectly detected by some antivirus, submit requests to remove detection.
|
||||
|
||||
"Microsoft" antivirus is built in Windows and will break user setups so removing its detection should be highest priority.
|
||||
To submit false positive to Microsoft, go to <https://www.microsoft.com/en-us/wdsi/filesubmission> and select "Submit file as a ... Software developer" option.
|
||||
|
||||
False positive contacts for other vendors can be found at <https://docs.virustotal.com/docs/false-positive-contacts>.
|
||||
Not all of them may be up to date, so check the links below first.
|
||||
Previously we successfully used the following contacts:
|
||||
- [ESET-NOD32](mailto:samples@eset.com)
|
||||
- [Symantec](https://symsubmit.symantec.com/)
|
||||
|
||||
## Dealing with failed releases
|
||||
|
||||
Once you make a GitHub release,
|
||||
CI will try to build and publish [PyPI](https://pypi.org/) and [npm](https://www.npmjs.com/) packages.
|
||||
If this fails for some reason, do not modify the failed tag, do not delete it and do not force-push to the `main` branch.
|
||||
Fix the build process and tag a new release instead.
|
||||
|
||||
11
STYLE.md
11
STYLE.md
@@ -16,11 +16,12 @@ id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
text TEXT DEFAULT '' NOT NULL -- message text
|
||||
) STRICT",
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
.context("CREATE TABLE messages")?;
|
||||
```
|
||||
|
||||
Do not use macros like [`concat!`](https://doc.rust-lang.org/std/macro.concat.html)
|
||||
or [`indoc!](https://docs.rs/indoc).
|
||||
or [`indoc!`](https://docs.rs/indoc).
|
||||
Do not escape newlines like this:
|
||||
```
|
||||
sql.execute(
|
||||
@@ -29,7 +30,8 @@ id INTEGER PRIMARY KEY AUTOINCREMENT, \
|
||||
text TEXT DEFAULT '' NOT NULL \
|
||||
) STRICT",
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
.context("CREATE TABLE messages")?;
|
||||
```
|
||||
Escaping newlines
|
||||
is prone to errors like this if space before backslash is missing:
|
||||
@@ -63,6 +65,9 @@ an older version. Also don't change the column type, consider adding a new colum
|
||||
instead. Finally, never change column semantics, this is especially dangerous because the `STRICT`
|
||||
keyword doesn't help here.
|
||||
|
||||
Consider adding context to `anyhow` errors for SQL statements using `.context()` so that it's
|
||||
possible to understand from logs which statement failed. See [Errors](#errors) for more info.
|
||||
|
||||
## Errors
|
||||
|
||||
Delta Chat core mostly uses [`anyhow`](https://docs.rs/anyhow/) errors.
|
||||
|
||||
@@ -8,43 +8,47 @@
|
||||
//! cargo bench --bench decrypting --features="internals"
|
||||
//! ```
|
||||
//!
|
||||
//! or, if you want to only run e.g. the 'Decrypt a symmetrically encrypted message' benchmark:
|
||||
//! or, if you want to only run e.g. the 'Decrypt and parse a symmetrically encrypted message' benchmark:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo bench --bench decrypting --features="internals" -- 'Decrypt a symmetrically encrypted message'
|
||||
//! cargo bench --bench decrypting --features="internals" -- 'Decrypt and parse a symmetrically encrypted message'
|
||||
//! ```
|
||||
//!
|
||||
//! You can also pass a substring.
|
||||
//! So, you can run all 'Decrypt and parse' benchmarks with:
|
||||
//! You can also pass a substring:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo bench --bench decrypting --features="internals" -- 'Decrypt and parse'
|
||||
//! cargo bench --bench decrypting --features="internals" -- 'symmetrically'
|
||||
//! ```
|
||||
//!
|
||||
//! Symmetric decryption has to try out all known secrets,
|
||||
//! You can benchmark this by adapting the `NUM_SECRETS` variable.
|
||||
|
||||
use std::hint::black_box;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use deltachat::internals_for_benches::create_broadcast_secret;
|
||||
use deltachat::internals_for_benches::create_dummy_keypair;
|
||||
use deltachat::internals_for_benches::save_broadcast_secret;
|
||||
use deltachat::securejoin::get_securejoin_qr;
|
||||
use deltachat::{
|
||||
Events,
|
||||
chat::ChatId,
|
||||
config::Config,
|
||||
context::Context,
|
||||
internals_for_benches::key_from_asc,
|
||||
internals_for_benches::parse_and_get_text,
|
||||
internals_for_benches::store_self_keypair,
|
||||
pgp::{KeyPair, decrypt, pk_encrypt, symm_encrypt_message},
|
||||
Events, chat::ChatId, config::Config, context::Context, internals_for_benches::key_from_asc,
|
||||
internals_for_benches::parse_and_get_text, internals_for_benches::store_self_keypair,
|
||||
stock_str::StockStrings,
|
||||
};
|
||||
use rand::{Rng, rng};
|
||||
use tempfile::tempdir;
|
||||
|
||||
const NUM_SECRETS: usize = 500;
|
||||
static NUM_BROADCAST_SECRETS: LazyLock<usize> = LazyLock::new(|| {
|
||||
std::env::var("NUM_BROADCAST_SECRETS")
|
||||
.unwrap_or("500".to_string())
|
||||
.parse()
|
||||
.unwrap()
|
||||
});
|
||||
static NUM_AUTH_TOKENS: LazyLock<usize> = LazyLock::new(|| {
|
||||
std::env::var("NUM_AUTH_TOKENS")
|
||||
.unwrap_or("5000".to_string())
|
||||
.parse()
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
async fn create_context() -> Context {
|
||||
let dir = tempdir().unwrap();
|
||||
@@ -58,9 +62,7 @@ async fn create_context() -> Context {
|
||||
.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)
|
||||
store_self_keypair(&context, &secret)
|
||||
.await
|
||||
.expect("Failed to save key");
|
||||
|
||||
@@ -70,65 +72,6 @@ async fn create_context() -> Context {
|
||||
fn criterion_benchmark(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("Decrypt");
|
||||
|
||||
// ===========================================================================================
|
||||
// Benchmarks for decryption only, without any other parsing
|
||||
// ===========================================================================================
|
||||
|
||||
group.sample_size(10);
|
||||
|
||||
group.bench_function("Decrypt a symmetrically encrypted message", |b| {
|
||||
let plain = generate_plaintext();
|
||||
let secrets = generate_secrets();
|
||||
let encrypted = tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||
let secret = secrets[NUM_SECRETS / 2].clone();
|
||||
symm_encrypt_message(
|
||||
plain.clone(),
|
||||
create_dummy_keypair("alice@example.org").unwrap().secret,
|
||||
black_box(&secret),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
b.iter(|| {
|
||||
let mut msg =
|
||||
decrypt(encrypted.clone().into_bytes(), &[], black_box(&secrets)).unwrap();
|
||||
let decrypted = msg.as_data_vec().unwrap();
|
||||
|
||||
assert_eq!(black_box(decrypted), plain);
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("Decrypt a public-key encrypted message", |b| {
|
||||
let plain = generate_plaintext();
|
||||
let key_pair = create_dummy_keypair("alice@example.org").unwrap();
|
||||
let secrets = generate_secrets();
|
||||
let encrypted = tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||
pk_encrypt(
|
||||
plain.clone(),
|
||||
vec![black_box(key_pair.public.clone())],
|
||||
Some(key_pair.secret.clone()),
|
||||
true,
|
||||
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())
|
||||
// ===========================================================================================
|
||||
@@ -138,7 +81,7 @@ fn criterion_benchmark(c: &mut Criterion) {
|
||||
|
||||
// "secret" is the shared secret that was used to encrypt text_symmetrically_encrypted.eml.
|
||||
// Put it into the middle of our secrets:
|
||||
secrets[NUM_SECRETS / 2] = "secret".to_string();
|
||||
secrets[*NUM_BROADCAST_SECRETS / 2] = "secret".to_string();
|
||||
|
||||
let context = rt.block_on(async {
|
||||
let context = create_context().await;
|
||||
@@ -147,6 +90,10 @@ fn criterion_benchmark(c: &mut Criterion) {
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
for _i in 0..*NUM_AUTH_TOKENS {
|
||||
get_securejoin_qr(&context, None).await.unwrap();
|
||||
}
|
||||
println!("NUM_AUTH_TOKENS={}", *NUM_AUTH_TOKENS);
|
||||
context
|
||||
});
|
||||
|
||||
@@ -160,7 +107,7 @@ fn criterion_benchmark(c: &mut Criterion) {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(text, "Symmetrically encrypted message");
|
||||
assert_eq!(black_box(text), "Symmetrically encrypted message");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -175,7 +122,7 @@ fn criterion_benchmark(c: &mut Criterion) {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(text, "hi");
|
||||
assert_eq!(black_box(text), "hi");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -184,17 +131,12 @@ fn criterion_benchmark(c: &mut Criterion) {
|
||||
}
|
||||
|
||||
fn generate_secrets() -> Vec<String> {
|
||||
let secrets: Vec<String> = (0..NUM_SECRETS)
|
||||
let secrets: Vec<String> = (0..*NUM_BROADCAST_SECRETS)
|
||||
.map(|_| create_broadcast_secret())
|
||||
.collect();
|
||||
println!("NUM_BROADCAST_SECRETS={}", *NUM_BROADCAST_SECRETS);
|
||||
secrets
|
||||
}
|
||||
|
||||
fn generate_plaintext() -> Vec<u8> {
|
||||
let mut plain: Vec<u8> = vec![0; 500];
|
||||
rng().fill(&mut plain[..]);
|
||||
plain
|
||||
}
|
||||
|
||||
criterion_group!(benches, criterion_benchmark);
|
||||
criterion_main!(benches);
|
||||
|
||||
@@ -66,7 +66,7 @@ body = """
|
||||
{% for commit in commits %}
|
||||
- {% if commit.breaking %}[**breaking**] {% endif %}\
|
||||
{% if commit.scope %}{{ commit.scope }}: {% endif %}\
|
||||
{{ commit.message | upper_first }}.\
|
||||
{{ commit.message }}.\
|
||||
{% if commit.footers is defined %}\
|
||||
{% for footer in commit.footers %}{% if 'BREAKING CHANGE' in footer.token %}
|
||||
{% raw %} {% endraw %}- {{ footer.value }}\
|
||||
|
||||
@@ -36,6 +36,45 @@ impl VcardContact {
|
||||
}
|
||||
}
|
||||
|
||||
fn escape(s: &str) -> String {
|
||||
// https://www.rfc-editor.org/rfc/rfc6350.html#section-3.4
|
||||
s
|
||||
// backslash must be first!
|
||||
.replace(r"\", r"\\")
|
||||
.replace(',', r"\,")
|
||||
.replace(';', r"\;")
|
||||
.replace('\n', r"\n")
|
||||
}
|
||||
|
||||
fn unescape(s: &str) -> String {
|
||||
// https://www.rfc-editor.org/rfc/rfc6350.html#section-3.4
|
||||
let mut out = String::new();
|
||||
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\\' {
|
||||
if let Some(next) = chars.next() {
|
||||
match next {
|
||||
'\\' | ',' | ';' => out.push(next),
|
||||
'n' | 'N' => out.push('\n'),
|
||||
_ => {
|
||||
// Invalid escape sequence (keep unchanged)
|
||||
out.push('\\');
|
||||
out.push(next);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Invalid escape sequence (keep unchanged)
|
||||
out.push('\\');
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Returns a vCard containing given contacts.
|
||||
///
|
||||
/// Calling [`parse_vcard()`] on the returned result is a reverse operation.
|
||||
@@ -46,10 +85,6 @@ pub fn make_vcard(contacts: &[VcardContact]) -> String {
|
||||
Some(datetime.format("%Y%m%dT%H%M%SZ").to_string())
|
||||
}
|
||||
|
||||
fn escape(s: &str) -> String {
|
||||
s.replace(',', "\\,")
|
||||
}
|
||||
|
||||
let mut res = "".to_string();
|
||||
for c in contacts {
|
||||
// Mustn't contain ',', but it's easier to escape than to error out.
|
||||
@@ -124,7 +159,7 @@ pub fn parse_vcard(vcard: &str) -> Vec<VcardContact> {
|
||||
fn vcard_property<'a>(line: &'a str, property: &str) -> Option<(&'a str, String)> {
|
||||
let (params, value) = vcard_property_raw(line, property)?;
|
||||
// Some fields can't contain commas, but unescape them everywhere for safety.
|
||||
Some((params, value.replace("\\,", ",")))
|
||||
Some((params, unescape(value)))
|
||||
}
|
||||
fn base64_key(line: &str) -> Option<&str> {
|
||||
let (params, value) = vcard_property_raw(line, "key")?;
|
||||
|
||||
@@ -91,7 +91,7 @@ fn test_make_and_parse_vcard() {
|
||||
authname: "Alice Wonderland".to_string(),
|
||||
key: Some("[base64-data]".to_string()),
|
||||
profile_image: Some("image in Base64".to_string()),
|
||||
biography: Some("Hi, I'm Alice".to_string()),
|
||||
biography: Some("Hi,\nI'm Alice; and this is a backslash: \\".to_string()),
|
||||
timestamp: Ok(1713465762),
|
||||
},
|
||||
VcardContact {
|
||||
@@ -110,7 +110,7 @@ fn test_make_and_parse_vcard() {
|
||||
FN:Alice Wonderland\r\n\
|
||||
KEY:data:application/pgp-keys;base64\\,[base64-data]\r\n\
|
||||
PHOTO:data:image/jpeg;base64\\,image in Base64\r\n\
|
||||
NOTE:Hi\\, I'm Alice\r\n\
|
||||
NOTE:Hi\\,\\nI'm Alice\\; and this is a backslash: \\\\\r\n\
|
||||
REV:20240418T184242Z\r\n\
|
||||
END:VCARD\r\n",
|
||||
"BEGIN:VCARD\r\n\
|
||||
@@ -276,3 +276,14 @@ END:VCARD",
|
||||
assert!(contacts[0].timestamp.is_err());
|
||||
assert_eq!(contacts[0].profile_image.as_ref().unwrap(), "/9aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vcard_value_escape_unescape() {
|
||||
let original = "Text, with; chars and a \\ and a newline\nand a literal newline \\n";
|
||||
let expected_escaped = r"Text\, with\; chars and a \\ and a newline\nand a literal newline \\n";
|
||||
|
||||
let escaped = escape(original);
|
||||
assert_eq!(escaped, expected_escaped);
|
||||
let unescaped = unescape(&escaped);
|
||||
assert_eq!(original, unescaped);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat_ffi"
|
||||
version = "2.27.0"
|
||||
version = "2.46.0-dev"
|
||||
description = "Deltachat FFI"
|
||||
edition = "2018"
|
||||
readme = "README.md"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,10 +15,10 @@ use std::collections::BTreeMap;
|
||||
use std::convert::TryFrom;
|
||||
use std::fmt::Write;
|
||||
use std::future::Future;
|
||||
use std::ops::Deref;
|
||||
use std::mem::ManuallyDrop;
|
||||
use std::ptr;
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use anyhow::Context as _;
|
||||
@@ -559,6 +559,7 @@ pub unsafe extern "C" fn dc_event_get_id(event: *mut dc_event_t) -> libc::c_int
|
||||
EventType::IncomingCallAccepted { .. } => 2560,
|
||||
EventType::OutgoingCallAccepted { .. } => 2570,
|
||||
EventType::CallEnded { .. } => 2580,
|
||||
EventType::TransportsModified => 2600,
|
||||
#[allow(unreachable_patterns)]
|
||||
#[cfg(test)]
|
||||
_ => unreachable!("This is just to silence a rust_analyzer false-positive"),
|
||||
@@ -593,7 +594,8 @@ pub unsafe extern "C" fn dc_event_get_data1_int(event: *mut dc_event_t) -> libc:
|
||||
| EventType::AccountsBackgroundFetchDone
|
||||
| EventType::ChatlistChanged
|
||||
| EventType::AccountsChanged
|
||||
| EventType::AccountsItemChanged => 0,
|
||||
| EventType::AccountsItemChanged
|
||||
| EventType::TransportsModified => 0,
|
||||
EventType::IncomingReaction { contact_id, .. }
|
||||
| EventType::IncomingWebxdcNotify { contact_id, .. } => contact_id.to_u32() as libc::c_int,
|
||||
EventType::MsgsChanged { chat_id, .. }
|
||||
@@ -678,10 +680,10 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
|
||||
| EventType::ChatModified(_)
|
||||
| EventType::ChatDeleted { .. }
|
||||
| EventType::WebxdcRealtimeAdvertisementReceived { .. }
|
||||
| EventType::IncomingCallAccepted { .. }
|
||||
| EventType::OutgoingCallAccepted { .. }
|
||||
| EventType::CallEnded { .. }
|
||||
| EventType::EventChannelOverflow { .. } => 0,
|
||||
| EventType::EventChannelOverflow { .. }
|
||||
| EventType::TransportsModified => 0,
|
||||
EventType::MsgsChanged { msg_id, .. }
|
||||
| EventType::ReactionsChanged { msg_id, .. }
|
||||
| EventType::IncomingReaction { msg_id, .. }
|
||||
@@ -700,6 +702,9 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
|
||||
} => status_update_serial.to_u32() as libc::c_int,
|
||||
EventType::WebxdcRealtimeData { data, .. } => data.len() as libc::c_int,
|
||||
EventType::IncomingCall { has_video, .. } => *has_video as libc::c_int,
|
||||
EventType::IncomingCallAccepted {
|
||||
from_this_device, ..
|
||||
} => *from_this_device as libc::c_int,
|
||||
|
||||
#[allow(unreachable_patterns)]
|
||||
#[cfg(test)]
|
||||
@@ -780,7 +785,8 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut
|
||||
| EventType::AccountsChanged
|
||||
| EventType::AccountsItemChanged
|
||||
| EventType::IncomingCallAccepted { .. }
|
||||
| EventType::WebxdcRealtimeAdvertisementReceived { .. } => ptr::null_mut(),
|
||||
| EventType::WebxdcRealtimeAdvertisementReceived { .. }
|
||||
| EventType::TransportsModified => ptr::null_mut(),
|
||||
EventType::IncomingCall {
|
||||
place_call_info, ..
|
||||
} => {
|
||||
@@ -1178,6 +1184,7 @@ pub unsafe extern "C" fn dc_place_outgoing_call(
|
||||
context: *mut dc_context_t,
|
||||
chat_id: u32,
|
||||
place_call_info: *const libc::c_char,
|
||||
has_video: bool,
|
||||
) -> u32 {
|
||||
if context.is_null() || chat_id == 0 {
|
||||
eprintln!("ignoring careless call to dc_place_outgoing_call()");
|
||||
@@ -1187,7 +1194,7 @@ pub unsafe extern "C" fn dc_place_outgoing_call(
|
||||
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))
|
||||
block_on(ctx.place_outgoing_call(chat_id, place_call_info, has_video))
|
||||
.context("Failed to place call")
|
||||
.log_err(ctx)
|
||||
.map(|msg_id| msg_id.to_u32())
|
||||
@@ -2257,22 +2264,6 @@ pub unsafe extern "C" fn dc_get_contacts(
|
||||
})
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_get_blocked_cnt(context: *mut dc_context_t) -> libc::c_int {
|
||||
if context.is_null() {
|
||||
eprintln!("ignoring careless call to dc_get_blocked_cnt()");
|
||||
return 0;
|
||||
}
|
||||
let ctx = &*context;
|
||||
|
||||
block_on(async move {
|
||||
Contact::get_all_blocked(ctx)
|
||||
.await
|
||||
.unwrap_or_log_default(ctx, "failed to get blocked count")
|
||||
.len() as libc::c_int
|
||||
})
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_get_blocked_contacts(
|
||||
context: *mut dc_context_t,
|
||||
@@ -4735,33 +4726,13 @@ pub unsafe extern "C" fn dc_provider_unref(provider: *mut dc_provider_t) {
|
||||
|
||||
/// Reader-writer lock wrapper for accounts manager to guarantee thread safety when using
|
||||
/// `dc_accounts_t` in multiple threads at once.
|
||||
pub struct AccountsWrapper {
|
||||
inner: Arc<RwLock<Accounts>>,
|
||||
}
|
||||
|
||||
impl Deref for AccountsWrapper {
|
||||
type Target = Arc<RwLock<Accounts>>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl AccountsWrapper {
|
||||
fn new(accounts: Accounts) -> Self {
|
||||
let inner = Arc::new(RwLock::new(accounts));
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
/// Struct representing a list of deltachat accounts.
|
||||
pub type dc_accounts_t = AccountsWrapper;
|
||||
pub type dc_accounts_t = RwLock<Accounts>;
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_accounts_new(
|
||||
dir: *const libc::c_char,
|
||||
writable: libc::c_int,
|
||||
) -> *mut dc_accounts_t {
|
||||
) -> *const dc_accounts_t {
|
||||
setup_panic!();
|
||||
|
||||
if dir.is_null() {
|
||||
@@ -4772,7 +4743,99 @@ pub unsafe extern "C" fn dc_accounts_new(
|
||||
let accs = block_on(Accounts::new(as_path(dir).into(), writable != 0));
|
||||
|
||||
match accs {
|
||||
Ok(accs) => Box::into_raw(Box::new(AccountsWrapper::new(accs))),
|
||||
Ok(accs) => Arc::into_raw(Arc::new(RwLock::new(accs))),
|
||||
Err(err) => {
|
||||
// We are using Anyhow's .context() and to show the inner error, too, we need the {:#}:
|
||||
eprintln!("failed to create accounts: {err:#}");
|
||||
ptr::null_mut()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type dc_event_channel_t = Mutex<Option<Events>>;
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_event_channel_new() -> *mut dc_event_channel_t {
|
||||
Box::into_raw(Box::new(Mutex::new(Some(Events::new()))))
|
||||
}
|
||||
|
||||
/// Release the events channel structure.
|
||||
///
|
||||
/// This function releases the memory of the `dc_event_channel_t` structure.
|
||||
///
|
||||
/// you can call it after calling dc_accounts_new_with_event_channel,
|
||||
/// which took the events channel out of it already, so this just frees the underlying option.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_event_channel_unref(event_channel: *mut dc_event_channel_t) {
|
||||
if event_channel.is_null() {
|
||||
eprintln!("ignoring careless call to dc_event_channel_unref()");
|
||||
return;
|
||||
}
|
||||
drop(Box::from_raw(event_channel))
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_event_channel_get_event_emitter(
|
||||
event_channel: *mut dc_event_channel_t,
|
||||
) -> *mut dc_event_emitter_t {
|
||||
if event_channel.is_null() {
|
||||
eprintln!("ignoring careless call to dc_event_channel_get_event_emitter()");
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
let Some(event_channel) = &*(*event_channel)
|
||||
.lock()
|
||||
.expect("call to dc_event_channel_get_event_emitter() failed: mutex is poisoned")
|
||||
else {
|
||||
eprintln!(
|
||||
"ignoring careless call to dc_event_channel_get_event_emitter()
|
||||
-> channel was already consumed, make sure you call this before dc_accounts_new_with_event_channel"
|
||||
);
|
||||
return ptr::null_mut();
|
||||
};
|
||||
|
||||
let emitter = event_channel.get_emitter();
|
||||
|
||||
Box::into_raw(Box::new(emitter))
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_accounts_new_with_event_channel(
|
||||
dir: *const libc::c_char,
|
||||
writable: libc::c_int,
|
||||
event_channel: *mut dc_event_channel_t,
|
||||
) -> *const dc_accounts_t {
|
||||
setup_panic!();
|
||||
|
||||
if dir.is_null() || event_channel.is_null() {
|
||||
eprintln!("ignoring careless call to dc_accounts_new_with_event_channel()");
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
// consuming channel enforce that you need to get the event emitter
|
||||
// before initializing the account manager,
|
||||
// so that you don't miss events/errors during initialisation.
|
||||
// It also prevents you from using the same channel on multiple account managers.
|
||||
let Some(event_channel) = (*event_channel)
|
||||
.lock()
|
||||
.expect("call to dc_event_channel_get_event_emitter() failed: mutex is poisoned")
|
||||
.take()
|
||||
else {
|
||||
eprintln!(
|
||||
"ignoring careless call to dc_accounts_new_with_event_channel()
|
||||
-> channel was already consumed"
|
||||
);
|
||||
return ptr::null_mut();
|
||||
};
|
||||
|
||||
let accs = block_on(Accounts::new_with_events(
|
||||
as_path(dir).into(),
|
||||
writable != 0,
|
||||
event_channel,
|
||||
));
|
||||
|
||||
match accs {
|
||||
Ok(accs) => Arc::into_raw(Arc::new(RwLock::new(accs))),
|
||||
Err(err) => {
|
||||
// We are using Anyhow's .context() and to show the inner error, too, we need the {:#}:
|
||||
eprintln!("failed to create accounts: {err:#}");
|
||||
@@ -4785,17 +4848,17 @@ pub unsafe extern "C" fn dc_accounts_new(
|
||||
///
|
||||
/// This function releases the memory of the `dc_accounts_t` structure.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_accounts_unref(accounts: *mut dc_accounts_t) {
|
||||
pub unsafe extern "C" fn dc_accounts_unref(accounts: *const dc_accounts_t) {
|
||||
if accounts.is_null() {
|
||||
eprintln!("ignoring careless call to dc_accounts_unref()");
|
||||
return;
|
||||
}
|
||||
let _ = Box::from_raw(accounts);
|
||||
drop(Arc::from_raw(accounts));
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_accounts_get_account(
|
||||
accounts: *mut dc_accounts_t,
|
||||
accounts: *const dc_accounts_t,
|
||||
id: u32,
|
||||
) -> *mut dc_context_t {
|
||||
if accounts.is_null() {
|
||||
@@ -4812,7 +4875,7 @@ pub unsafe extern "C" fn dc_accounts_get_account(
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_accounts_get_selected_account(
|
||||
accounts: *mut dc_accounts_t,
|
||||
accounts: *const dc_accounts_t,
|
||||
) -> *mut dc_context_t {
|
||||
if accounts.is_null() {
|
||||
eprintln!("ignoring careless call to dc_accounts_get_selected_account()");
|
||||
@@ -4828,7 +4891,7 @@ pub unsafe extern "C" fn dc_accounts_get_selected_account(
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_accounts_select_account(
|
||||
accounts: *mut dc_accounts_t,
|
||||
accounts: *const dc_accounts_t,
|
||||
id: u32,
|
||||
) -> libc::c_int {
|
||||
if accounts.is_null() {
|
||||
@@ -4852,13 +4915,13 @@ pub unsafe extern "C" fn dc_accounts_select_account(
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_accounts_add_account(accounts: *mut dc_accounts_t) -> u32 {
|
||||
pub unsafe extern "C" fn dc_accounts_add_account(accounts: *const dc_accounts_t) -> u32 {
|
||||
if accounts.is_null() {
|
||||
eprintln!("ignoring careless call to dc_accounts_add_account()");
|
||||
return 0;
|
||||
}
|
||||
|
||||
let accounts = &mut *accounts;
|
||||
let accounts = &*accounts;
|
||||
|
||||
block_on(async move {
|
||||
let mut accounts = accounts.write().await;
|
||||
@@ -4873,13 +4936,13 @@ pub unsafe extern "C" fn dc_accounts_add_account(accounts: *mut dc_accounts_t) -
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_accounts_add_closed_account(accounts: *mut dc_accounts_t) -> u32 {
|
||||
pub unsafe extern "C" fn dc_accounts_add_closed_account(accounts: *const dc_accounts_t) -> u32 {
|
||||
if accounts.is_null() {
|
||||
eprintln!("ignoring careless call to dc_accounts_add_closed_account()");
|
||||
return 0;
|
||||
}
|
||||
|
||||
let accounts = &mut *accounts;
|
||||
let accounts = &*accounts;
|
||||
|
||||
block_on(async move {
|
||||
let mut accounts = accounts.write().await;
|
||||
@@ -4895,7 +4958,7 @@ pub unsafe extern "C" fn dc_accounts_add_closed_account(accounts: *mut dc_accoun
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_accounts_remove_account(
|
||||
accounts: *mut dc_accounts_t,
|
||||
accounts: *const dc_accounts_t,
|
||||
id: u32,
|
||||
) -> libc::c_int {
|
||||
if accounts.is_null() {
|
||||
@@ -4903,7 +4966,7 @@ pub unsafe extern "C" fn dc_accounts_remove_account(
|
||||
return 0;
|
||||
}
|
||||
|
||||
let accounts = &mut *accounts;
|
||||
let accounts = &*accounts;
|
||||
|
||||
block_on(async move {
|
||||
let mut accounts = accounts.write().await;
|
||||
@@ -4921,7 +4984,7 @@ pub unsafe extern "C" fn dc_accounts_remove_account(
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_accounts_migrate_account(
|
||||
accounts: *mut dc_accounts_t,
|
||||
accounts: *const dc_accounts_t,
|
||||
dbfile: *const libc::c_char,
|
||||
) -> u32 {
|
||||
if accounts.is_null() || dbfile.is_null() {
|
||||
@@ -4929,7 +4992,7 @@ pub unsafe extern "C" fn dc_accounts_migrate_account(
|
||||
return 0;
|
||||
}
|
||||
|
||||
let accounts = &mut *accounts;
|
||||
let accounts = &*accounts;
|
||||
let dbfile = to_string_lossy(dbfile);
|
||||
|
||||
block_on(async move {
|
||||
@@ -4950,7 +5013,7 @@ pub unsafe extern "C" fn dc_accounts_migrate_account(
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_accounts_get_all(accounts: *mut dc_accounts_t) -> *mut dc_array_t {
|
||||
pub unsafe extern "C" fn dc_accounts_get_all(accounts: *const dc_accounts_t) -> *mut dc_array_t {
|
||||
if accounts.is_null() {
|
||||
eprintln!("ignoring careless call to dc_accounts_get_all()");
|
||||
return ptr::null_mut();
|
||||
@@ -4964,18 +5027,18 @@ pub unsafe extern "C" fn dc_accounts_get_all(accounts: *mut dc_accounts_t) -> *m
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_accounts_start_io(accounts: *mut dc_accounts_t) {
|
||||
pub unsafe extern "C" fn dc_accounts_start_io(accounts: *const dc_accounts_t) {
|
||||
if accounts.is_null() {
|
||||
eprintln!("ignoring careless call to dc_accounts_start_io()");
|
||||
return;
|
||||
}
|
||||
|
||||
let accounts = &mut *accounts;
|
||||
let accounts = &*accounts;
|
||||
block_on(async move { accounts.write().await.start_io().await });
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_accounts_stop_io(accounts: *mut dc_accounts_t) {
|
||||
pub unsafe extern "C" fn dc_accounts_stop_io(accounts: *const dc_accounts_t) {
|
||||
if accounts.is_null() {
|
||||
eprintln!("ignoring careless call to dc_accounts_stop_io()");
|
||||
return;
|
||||
@@ -4986,7 +5049,7 @@ pub unsafe extern "C" fn dc_accounts_stop_io(accounts: *mut dc_accounts_t) {
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_accounts_maybe_network(accounts: *mut dc_accounts_t) {
|
||||
pub unsafe extern "C" fn dc_accounts_maybe_network(accounts: *const dc_accounts_t) {
|
||||
if accounts.is_null() {
|
||||
eprintln!("ignoring careless call to dc_accounts_maybe_network()");
|
||||
return;
|
||||
@@ -4997,7 +5060,7 @@ pub unsafe extern "C" fn dc_accounts_maybe_network(accounts: *mut dc_accounts_t)
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_accounts_maybe_network_lost(accounts: *mut dc_accounts_t) {
|
||||
pub unsafe extern "C" fn dc_accounts_maybe_network_lost(accounts: *const dc_accounts_t) {
|
||||
if accounts.is_null() {
|
||||
eprintln!("ignoring careless call to dc_accounts_maybe_network_lost()");
|
||||
return;
|
||||
@@ -5009,7 +5072,7 @@ pub unsafe extern "C" fn dc_accounts_maybe_network_lost(accounts: *mut dc_accoun
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_accounts_background_fetch(
|
||||
accounts: *mut dc_accounts_t,
|
||||
accounts: *const dc_accounts_t,
|
||||
timeout_in_seconds: u64,
|
||||
) -> libc::c_int {
|
||||
if accounts.is_null() || timeout_in_seconds <= 2 {
|
||||
@@ -5028,7 +5091,7 @@ pub unsafe extern "C" fn dc_accounts_background_fetch(
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_accounts_stop_background_fetch(accounts: *mut dc_accounts_t) {
|
||||
pub unsafe extern "C" fn dc_accounts_stop_background_fetch(accounts: *const dc_accounts_t) {
|
||||
if accounts.is_null() {
|
||||
eprintln!("ignoring careless call to dc_accounts_stop_background_fetch()");
|
||||
return;
|
||||
@@ -5040,7 +5103,7 @@ pub unsafe extern "C" fn dc_accounts_stop_background_fetch(accounts: *mut dc_acc
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_accounts_set_push_device_token(
|
||||
accounts: *mut dc_accounts_t,
|
||||
accounts: *const dc_accounts_t,
|
||||
token: *const libc::c_char,
|
||||
) {
|
||||
if accounts.is_null() {
|
||||
@@ -5063,7 +5126,7 @@ pub unsafe extern "C" fn dc_accounts_set_push_device_token(
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_accounts_get_event_emitter(
|
||||
accounts: *mut dc_accounts_t,
|
||||
accounts: *const dc_accounts_t,
|
||||
) -> *mut dc_event_emitter_t {
|
||||
if accounts.is_null() {
|
||||
eprintln!("ignoring careless call to dc_accounts_get_event_emitter()");
|
||||
@@ -5083,17 +5146,17 @@ pub struct dc_jsonrpc_instance_t {
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_jsonrpc_init(
|
||||
account_manager: *mut dc_accounts_t,
|
||||
account_manager: *const dc_accounts_t,
|
||||
) -> *mut dc_jsonrpc_instance_t {
|
||||
if account_manager.is_null() {
|
||||
eprintln!("ignoring careless call to dc_jsonrpc_init()");
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
let account_manager = &*account_manager;
|
||||
let cmd_api = block_on(deltachat_jsonrpc::api::CommandApi::from_arc(
|
||||
account_manager.inner.clone(),
|
||||
));
|
||||
let account_manager = ManuallyDrop::new(Arc::from_raw(account_manager));
|
||||
let cmd_api = block_on(deltachat_jsonrpc::api::CommandApi::from_arc(Arc::clone(
|
||||
&account_manager,
|
||||
)));
|
||||
|
||||
let (request_handle, receiver) = RpcClient::new();
|
||||
let handle = RpcSession::new(request_handle, cmd_api);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-jsonrpc"
|
||||
version = "2.27.0"
|
||||
version = "2.46.0-dev"
|
||||
description = "DeltaChat JSON-RPC API"
|
||||
edition = "2021"
|
||||
license = "MPL-2.0"
|
||||
@@ -19,7 +19,6 @@ yerpc = { workspace = true, features = ["anyhow_expose", "openrpc"] }
|
||||
typescript-type-def = { version = "0.5.13", features = ["json_value"] }
|
||||
tokio = { workspace = true }
|
||||
sanitize-filename = { workspace = true }
|
||||
walkdir = "2.5.0"
|
||||
base64 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -10,37 +10,38 @@ pub use deltachat::accounts::Accounts;
|
||||
use deltachat::blob::BlobObject;
|
||||
use deltachat::calls::ice_servers;
|
||||
use deltachat::chat::{
|
||||
self, add_contact_to_chat, forward_msgs, get_chat_media, get_chat_msgs, get_chat_msgs_ex,
|
||||
marknoticed_chat, remove_contact_from_chat, Chat, ChatId, ChatItem, MessageListOptions,
|
||||
self, add_contact_to_chat, forward_msgs, forward_msgs_2ctx, get_chat_media, get_chat_msgs,
|
||||
get_chat_msgs_ex, marknoticed_all_chats, marknoticed_chat, remove_contact_from_chat, Chat,
|
||||
ChatId, ChatItem, MessageListOptions,
|
||||
};
|
||||
use deltachat::chatlist::Chatlist;
|
||||
use deltachat::config::Config;
|
||||
use deltachat::config::{get_all_ui_config_keys, Config};
|
||||
use deltachat::constants::DC_MSG_ID_DAYMARKER;
|
||||
use deltachat::contact::{may_be_valid_addr, Contact, ContactId, Origin};
|
||||
use deltachat::context::get_info;
|
||||
use deltachat::ephemeral::Timer;
|
||||
use deltachat::imex;
|
||||
use deltachat::location;
|
||||
use deltachat::message::get_msg_read_receipts;
|
||||
use deltachat::message::{
|
||||
self, delete_msgs_ex, markseen_msgs, Message, MessageState, MsgId, Viewtype,
|
||||
self, delete_msgs_ex, get_existing_msg_ids, get_msg_read_receipt_count, get_msg_read_receipts,
|
||||
markseen_msgs, Message, MessageState, MsgId, Viewtype,
|
||||
};
|
||||
use deltachat::peer_channels::{
|
||||
leave_webxdc_realtime, send_webxdc_realtime_advertisement, send_webxdc_realtime_data,
|
||||
};
|
||||
use deltachat::provider::get_provider_info;
|
||||
use deltachat::qr::{self, Qr};
|
||||
use deltachat::qr_code_generator::{generate_backup_qr, get_securejoin_qr_svg};
|
||||
use deltachat::qr_code_generator::{create_qr_svg, generate_backup_qr, get_securejoin_qr_svg};
|
||||
use deltachat::reaction::{get_msg_reactions, send_reaction};
|
||||
use deltachat::securejoin;
|
||||
use deltachat::stock_str::StockMessage;
|
||||
use deltachat::storage_usage::{get_blobdir_storage_usage, get_storage_usage};
|
||||
use deltachat::webxdc::StatusUpdateSerial;
|
||||
use deltachat::EventEmitter;
|
||||
use sanitize_filename::is_sanitized;
|
||||
use tokio::fs;
|
||||
use tokio::sync::{watch, Mutex, RwLock};
|
||||
use types::login_param::EnteredLoginParam;
|
||||
use walkdir::WalkDir;
|
||||
use yerpc::rpc;
|
||||
|
||||
pub mod types;
|
||||
@@ -120,14 +121,14 @@ impl CommandApi {
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_context_opt(&self, id: u32) -> Option<deltachat::context::Context> {
|
||||
self.accounts.read().await.get_account(id)
|
||||
}
|
||||
|
||||
async fn get_context(&self, id: u32) -> Result<deltachat::context::Context> {
|
||||
let sc = self
|
||||
.accounts
|
||||
.read()
|
||||
self.get_context_opt(id)
|
||||
.await
|
||||
.get_account(id)
|
||||
.ok_or_else(|| anyhow!("account with id {id} not found"))?;
|
||||
Ok(sc)
|
||||
.ok_or_else(|| anyhow!("account with id {id} not found"))
|
||||
}
|
||||
|
||||
async fn with_state<F, T>(&self, id: u32, with_state: F) -> T
|
||||
@@ -193,6 +194,16 @@ impl CommandApi {
|
||||
.context("event channel is closed")
|
||||
}
|
||||
|
||||
/// Waits for at least one event and return a batch of events.
|
||||
async fn get_next_event_batch(&self) -> Vec<Event> {
|
||||
self.event_emitter
|
||||
.recv_batch()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|event| event.into())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------
|
||||
// Account Management
|
||||
// ---------------------------------------------
|
||||
@@ -328,13 +339,7 @@ impl CommandApi {
|
||||
async fn get_account_file_size(&self, account_id: u32) -> Result<u64> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
let dbfile = ctx.get_dbfile().metadata()?.len();
|
||||
let total_size = WalkDir::new(ctx.get_blobdir())
|
||||
.max_depth(2)
|
||||
.into_iter()
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter_map(|entry| entry.metadata().ok())
|
||||
.filter(|metadata| metadata.is_file())
|
||||
.fold(0, |acc, m| acc + m.len());
|
||||
let total_size = get_blobdir_storage_usage(&ctx);
|
||||
|
||||
Ok(dbfile + total_size)
|
||||
}
|
||||
@@ -366,6 +371,13 @@ impl CommandApi {
|
||||
ctx.get_info().await
|
||||
}
|
||||
|
||||
/// Get storage usage report as formatted string
|
||||
async fn get_storage_usage_report_string(&self, account_id: u32) -> Result<String> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
let storage_usage = get_storage_usage(&ctx).await?;
|
||||
Ok(storage_usage.to_string())
|
||||
}
|
||||
|
||||
/// Get the blob dir.
|
||||
async fn get_blob_dir(&self, account_id: u32) -> Result<Option<String>> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
@@ -414,11 +426,11 @@ impl CommandApi {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set configuration values from a QR code. (technically from the URI that is stored in the qrcode)
|
||||
/// Before this function is called, `checkQr()` should confirm the type of the
|
||||
/// QR code is `account` or `webrtcInstance`.
|
||||
/// Set configuration values from a QR code (technically from the URI stored in it).
|
||||
/// Before this function is called, `check_qr()` should be used to get the QR code type.
|
||||
///
|
||||
/// Internally, the function will call dc_set_config() with the appropriate keys,
|
||||
/// "DCACCOUNT:" and "DCLOGIN:" QR codes configure the account, but I/O mustn't be started for
|
||||
/// such QR codes, consider using [`Self::add_transport_from_qr`] which also restarts I/O.
|
||||
async fn set_config_from_qr(&self, account_id: u32, qr_content: String) -> Result<()> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
qr::set_config_from_qr(&ctx, &qr_content).await
|
||||
@@ -450,6 +462,12 @@ impl CommandApi {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Returns all `ui.*` config keys that were set by the UI.
|
||||
async fn get_all_ui_config_keys(&self, account_id: u32) -> Result<Vec<String>> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
get_all_ui_config_keys(&ctx).await
|
||||
}
|
||||
|
||||
async fn set_stock_strings(&self, strings: HashMap<u32, String>) -> Result<()> {
|
||||
let accounts = self.accounts.read().await;
|
||||
for (stock_id, stock_message) in strings {
|
||||
@@ -793,11 +811,11 @@ impl CommandApi {
|
||||
/// Delete a chat.
|
||||
///
|
||||
/// Messages are deleted from the device and the chat database entry is deleted.
|
||||
/// After that, the event #DC_EVENT_MSGS_CHANGED is posted.
|
||||
/// After that, a `MsgsChanged` event is emitted.
|
||||
/// Messages are deleted from the server in background.
|
||||
///
|
||||
/// Things that are _not done_ implicitly:
|
||||
///
|
||||
/// - Messages are **not deleted from the server**.
|
||||
/// - The chat or the contact is **not blocked**, so new messages from the user/the group may appear as a contact request
|
||||
/// and the user may create the chat again.
|
||||
/// - **Groups are not left** - this would
|
||||
@@ -846,6 +864,8 @@ impl CommandApi {
|
||||
/// if `checkQr()` returns `askVerifyContact` or `askVerifyGroup`
|
||||
/// an out-of-band-verification can be joined using `secure_join()`
|
||||
///
|
||||
/// @deprecated as of 2026-03; use create_qr_svg(get_chat_securejoin_qr_code()) instead.
|
||||
///
|
||||
/// chat_id: If set to a group-chat-id,
|
||||
/// the Verified-Group-Invite protocol is offered in the QR code;
|
||||
/// works for protected groups as well as for normal groups.
|
||||
@@ -1060,7 +1080,8 @@ impl CommandApi {
|
||||
/// Set group name.
|
||||
///
|
||||
/// If the group is already _promoted_ (any message was sent to the group),
|
||||
/// all group members are informed by a special status message that is sent automatically by this function.
|
||||
/// or if this is a brodacast channel,
|
||||
/// all members are informed by a special status message that is sent automatically by this function.
|
||||
///
|
||||
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
|
||||
async fn set_chat_name(&self, account_id: u32, chat_id: u32, new_name: String) -> Result<()> {
|
||||
@@ -1068,10 +1089,39 @@ impl CommandApi {
|
||||
chat::set_chat_name(&ctx, ChatId::new(chat_id), &new_name).await
|
||||
}
|
||||
|
||||
/// Set group or broadcast channel description.
|
||||
///
|
||||
/// If the group is already _promoted_ (any message was sent to the group),
|
||||
/// or if this is a brodacast channel,
|
||||
/// all members are informed by a special status message that is sent automatically by this function.
|
||||
///
|
||||
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
|
||||
///
|
||||
/// See also [`Self::get_chat_description`] / `getChatDescription()`.
|
||||
async fn set_chat_description(
|
||||
&self,
|
||||
account_id: u32,
|
||||
chat_id: u32,
|
||||
description: String,
|
||||
) -> Result<()> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
chat::set_chat_description(&ctx, ChatId::new(chat_id), &description).await
|
||||
}
|
||||
|
||||
/// Load the chat description from the database.
|
||||
///
|
||||
/// UIs show this in the profile page of the chat,
|
||||
/// it is settable by [`Self::set_chat_description`] / `setChatDescription()`.
|
||||
async fn get_chat_description(&self, account_id: u32, chat_id: u32) -> Result<String> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
chat::get_chat_description(&ctx, ChatId::new(chat_id)).await
|
||||
}
|
||||
|
||||
/// Set group profile image.
|
||||
///
|
||||
/// If the group is already _promoted_ (any message was sent to the group),
|
||||
/// all group members are informed by a special status message that is sent automatically by this function.
|
||||
/// or if this is a brodacast channel,
|
||||
/// all members are informed by a special status message that is sent automatically by this function.
|
||||
///
|
||||
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
|
||||
///
|
||||
@@ -1156,10 +1206,24 @@ impl CommandApi {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Mark all messages in all chats as _noticed_.
|
||||
/// Skips messages from blocked contacts, but does not skip messages in muted chats.
|
||||
///
|
||||
/// _Noticed_ messages are no longer _fresh_ and do not count as being unseen
|
||||
/// but are still waiting for being marked as "seen" using markseen_msgs()
|
||||
/// (read receipts aren't sent for noticed messages).
|
||||
///
|
||||
/// Calling this function usually results in the event #DC_EVENT_MSGS_NOTICED.
|
||||
/// See also markseen_msgs().
|
||||
pub async fn marknoticed_all_chats(&self, account_id: u32) -> Result<()> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
marknoticed_all_chats(&ctx).await
|
||||
}
|
||||
|
||||
/// Mark all messages in a chat as _noticed_.
|
||||
/// _Noticed_ messages are no longer _fresh_ and do not count as being unseen
|
||||
/// but are still waiting for being marked as "seen" using markseen_msgs()
|
||||
/// (IMAP/MDNs is not done for noticed messages).
|
||||
/// (read receipts aren't sent for noticed messages).
|
||||
///
|
||||
/// Calling this function usually results in the event #DC_EVENT_MSGS_NOTICED.
|
||||
/// See also markseen_msgs().
|
||||
@@ -1295,6 +1359,24 @@ impl CommandApi {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Checks if the messages with given IDs exist.
|
||||
///
|
||||
/// Returns IDs of existing messages.
|
||||
async fn get_existing_msg_ids(&self, account_id: u32, msg_ids: Vec<u32>) -> Result<Vec<u32>> {
|
||||
if let Some(context) = self.get_context_opt(account_id).await {
|
||||
let msg_ids: Vec<MsgId> = msg_ids.into_iter().map(MsgId::new).collect();
|
||||
let existing_msg_ids = get_existing_msg_ids(&context, &msg_ids).await?;
|
||||
Ok(existing_msg_ids
|
||||
.into_iter()
|
||||
.map(|msg_id| msg_id.to_u32())
|
||||
.collect())
|
||||
} else {
|
||||
// Account does not exist, so messages do not exist either,
|
||||
// but this is not an error.
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_message_list_items(
|
||||
&self,
|
||||
account_id: u32,
|
||||
@@ -1408,6 +1490,18 @@ impl CommandApi {
|
||||
MessageInfo::from_msg_id(&ctx, MsgId::new(message_id)).await
|
||||
}
|
||||
|
||||
/// Returns count of read receipts on message.
|
||||
///
|
||||
/// This view count is meant as a feedback measure for the channel owner only.
|
||||
async fn get_message_read_receipt_count(
|
||||
&self,
|
||||
account_id: u32,
|
||||
message_id: u32,
|
||||
) -> Result<usize> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
get_msg_read_receipt_count(&ctx, MsgId::new(message_id)).await
|
||||
}
|
||||
|
||||
/// Returns contacts that sent read receipts and the time of reading.
|
||||
async fn get_message_read_receipts(
|
||||
&self,
|
||||
@@ -1888,6 +1982,8 @@ impl CommandApi {
|
||||
/// even if there is no concurrent call to [`CommandApi::provide_backup`],
|
||||
/// but will fail after 60 seconds to avoid deadlocks.
|
||||
///
|
||||
/// @deprecated as of 2026-03; use `create_qr_svg(get_backup_qr())` instead.
|
||||
///
|
||||
/// Returns the QR code rendered as an SVG image.
|
||||
async fn get_backup_qr_svg(&self, account_id: u32) -> Result<String> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
@@ -1901,6 +1997,11 @@ impl CommandApi {
|
||||
generate_backup_qr(&ctx, &qr).await
|
||||
}
|
||||
|
||||
/// Renders the given text as a QR code SVG image.
|
||||
async fn create_qr_svg(&self, text: String) -> Result<String> {
|
||||
create_qr_svg(&text)
|
||||
}
|
||||
|
||||
/// Gets a backup from a remote provider.
|
||||
///
|
||||
/// This retrieves the backup from a remote device over the network and imports it into
|
||||
@@ -2115,10 +2216,11 @@ impl CommandApi {
|
||||
account_id: u32,
|
||||
chat_id: u32,
|
||||
place_call_info: String,
|
||||
has_video: bool,
|
||||
) -> Result<u32> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
let msg_id = ctx
|
||||
.place_outgoing_call(ChatId::new(chat_id), place_call_info)
|
||||
.place_outgoing_call(ChatId::new(chat_id), place_call_info, has_video)
|
||||
.await?;
|
||||
Ok(msg_id.to_u32())
|
||||
}
|
||||
@@ -2182,6 +2284,27 @@ impl CommandApi {
|
||||
forward_msgs(&ctx, &message_ids, ChatId::new(chat_id)).await
|
||||
}
|
||||
|
||||
/// Forward messages to a chat in another account.
|
||||
/// See [`Self::forward_messages`] for more info.
|
||||
async fn forward_messages_to_account(
|
||||
&self,
|
||||
src_account_id: u32,
|
||||
src_message_ids: Vec<u32>,
|
||||
dst_account_id: u32,
|
||||
dst_chat_id: u32,
|
||||
) -> Result<()> {
|
||||
let src_ctx = self.get_context(src_account_id).await?;
|
||||
let dst_ctx = self.get_context(dst_account_id).await?;
|
||||
let src_message_ids: Vec<MsgId> = src_message_ids.into_iter().map(MsgId::new).collect();
|
||||
forward_msgs_2ctx(
|
||||
&src_ctx,
|
||||
&src_message_ids,
|
||||
&dst_ctx,
|
||||
ChatId::new(dst_chat_id),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -2392,7 +2515,10 @@ impl CommandApi {
|
||||
continue;
|
||||
}
|
||||
let sticker_name = sticker_entry.file_name().into_string().unwrap_or_default();
|
||||
if sticker_name.ends_with(".png") || sticker_name.ends_with(".webp") {
|
||||
if sticker_name.ends_with(".png")
|
||||
|| sticker_name.ends_with(".webp")
|
||||
|| sticker_name.ends_with(".gif")
|
||||
{
|
||||
sticker_paths.push(
|
||||
sticker_entry
|
||||
.path()
|
||||
|
||||
@@ -15,7 +15,7 @@ pub enum Account {
|
||||
display_name: Option<String>,
|
||||
addr: Option<String>,
|
||||
// size: u32,
|
||||
profile_image: Option<String>, // TODO: This needs to be converted to work with blob http server.
|
||||
profile_image: Option<String>,
|
||||
color: String,
|
||||
/// Optional tag as "Work", "Family".
|
||||
/// Meant to help profile owner to differ between profiles with similar names.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
|
||||
use deltachat::calls::{call_state, sdp_has_video, CallState};
|
||||
use deltachat::calls::{call_state, CallState};
|
||||
use deltachat::context::Context;
|
||||
use deltachat::message::MsgId;
|
||||
use serde::Serialize;
|
||||
@@ -15,7 +15,7 @@ pub struct JsonrpcCallInfo {
|
||||
/// even if incoming call event was missed.
|
||||
pub sdp_offer: String,
|
||||
|
||||
/// True if SDP offer has a video.
|
||||
/// True if the call is started as a video call.
|
||||
pub has_video: bool,
|
||||
|
||||
/// Call state.
|
||||
@@ -30,7 +30,7 @@ impl JsonrpcCallInfo {
|
||||
format!("Attempting to get call state of non-call message {msg_id}")
|
||||
})?;
|
||||
let sdp_offer = call_info.place_call_info.clone();
|
||||
let has_video = sdp_has_video(&sdp_offer).unwrap_or_default();
|
||||
let has_video = call_info.has_video_initially();
|
||||
let state = JsonrpcCallState::from_msg_id(context, msg_id).await?;
|
||||
|
||||
Ok(JsonrpcCallInfo {
|
||||
|
||||
@@ -10,7 +10,6 @@ use serde::{Deserialize, Serialize};
|
||||
use typescript_type_def::TypeDef;
|
||||
|
||||
use super::color_int_to_hex_string;
|
||||
use super::contact::ContactObject;
|
||||
|
||||
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -48,7 +47,6 @@ pub struct FullChat {
|
||||
chat_type: JsonrpcChatType,
|
||||
is_unpromoted: bool,
|
||||
is_self_talk: bool,
|
||||
contacts: Vec<ContactObject>,
|
||||
contact_ids: Vec<u32>,
|
||||
|
||||
/// Contact IDs of the past chat members.
|
||||
@@ -69,7 +67,7 @@ pub struct FullChat {
|
||||
// but that would be an extra DB query.
|
||||
self_in_group: bool,
|
||||
is_muted: bool,
|
||||
ephemeral_timer: u32, //TODO look if there are more important properties in newer core versions
|
||||
ephemeral_timer: u32,
|
||||
can_send: bool,
|
||||
was_seen_recently: bool,
|
||||
mailing_list_address: Option<String>,
|
||||
@@ -83,20 +81,6 @@ impl FullChat {
|
||||
let contact_ids = get_chat_contacts(context, rust_chat_id).await?;
|
||||
let past_contact_ids = get_past_chat_contacts(context, rust_chat_id).await?;
|
||||
|
||||
let mut contacts = Vec::with_capacity(contact_ids.len());
|
||||
|
||||
for contact_id in &contact_ids {
|
||||
contacts.push(
|
||||
ContactObject::try_from_dc_contact(
|
||||
context,
|
||||
Contact::get_by_id(context, *contact_id)
|
||||
.await
|
||||
.context("failed to load contact")?,
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
let profile_image = match chat.get_profile_image(context).await? {
|
||||
Some(path_buf) => path_buf.to_str().map(|s| s.to_owned()),
|
||||
None => None,
|
||||
@@ -132,7 +116,6 @@ impl FullChat {
|
||||
chat_type: chat.get_type().into(),
|
||||
is_unpromoted: chat.is_unpromoted(),
|
||||
is_self_talk: chat.is_self_talk(),
|
||||
contacts,
|
||||
contact_ids: contact_ids.iter().map(|id| id.to_u32()).collect(),
|
||||
past_contact_ids: past_contact_ids.iter().map(|id| id.to_u32()).collect(),
|
||||
color,
|
||||
@@ -150,7 +133,6 @@ impl FullChat {
|
||||
}
|
||||
|
||||
/// cheaper version of fullchat, omits:
|
||||
/// - contacts
|
||||
/// - contact_ids
|
||||
/// - fresh_message_counter
|
||||
/// - ephemeral_timer
|
||||
|
||||
@@ -47,8 +47,7 @@ pub struct ContactObject {
|
||||
///
|
||||
/// - If `verifierId` != 0,
|
||||
/// display text "Introduced by ..."
|
||||
/// with the name and address of the contact
|
||||
/// formatted by `name_and_addr`/`nameAndAddr`.
|
||||
/// with the name of the contact.
|
||||
/// Prefix the text by a green checkmark.
|
||||
///
|
||||
/// - If `verifierId` == 0 and `isVerified` != 0,
|
||||
|
||||
@@ -271,7 +271,7 @@ pub enum EventType {
|
||||
/// Progress.
|
||||
///
|
||||
/// 0=error, 1-999=progress in permille, 1000=success and done
|
||||
progress: usize,
|
||||
progress: u16,
|
||||
|
||||
/// Progress comment or error, something to display to the user.
|
||||
comment: Option<String>,
|
||||
@@ -282,7 +282,7 @@ pub enum EventType {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ImexProgress {
|
||||
/// 0=error, 1-999=progress in permille, 1000=success and done
|
||||
progress: usize,
|
||||
progress: u16,
|
||||
},
|
||||
|
||||
/// A file has been exported. A file has been written by imex().
|
||||
@@ -313,7 +313,7 @@ pub enum EventType {
|
||||
chat_id: u32,
|
||||
|
||||
/// Progress, always 1000.
|
||||
progress: usize,
|
||||
progress: u16,
|
||||
},
|
||||
|
||||
/// Progress information of a secure-join handshake from the view of the joiner
|
||||
@@ -329,7 +329,7 @@ pub enum EventType {
|
||||
/// 400=vg-/vc-request-with-auth sent, typically shown as "alice@addr verified, introducing myself."
|
||||
/// (Bob has verified alice and waits until Alice does the same for him)
|
||||
/// 1000=vg-member-added/vc-contact-confirm received
|
||||
progress: usize,
|
||||
progress: u16,
|
||||
},
|
||||
|
||||
/// The connectivity to the server changed.
|
||||
@@ -441,6 +441,8 @@ pub enum EventType {
|
||||
msg_id: u32,
|
||||
/// ID of the chat which the message belongs to.
|
||||
chat_id: u32,
|
||||
/// The call was accepted from this device (process).
|
||||
from_this_device: bool,
|
||||
},
|
||||
|
||||
/// Outgoing call accepted.
|
||||
@@ -460,6 +462,15 @@ pub enum EventType {
|
||||
/// ID of the chat which the message belongs to.
|
||||
chat_id: u32,
|
||||
},
|
||||
|
||||
/// One or more transports has changed.
|
||||
///
|
||||
/// UI should update the list.
|
||||
///
|
||||
/// This event is emitted when transport
|
||||
/// synchronization messages arrives,
|
||||
/// but not when the UI modifies the transport list by itself.
|
||||
TransportsModified,
|
||||
}
|
||||
|
||||
impl From<CoreEventType> for EventType {
|
||||
@@ -625,9 +636,14 @@ impl From<CoreEventType> for EventType {
|
||||
place_call_info,
|
||||
has_video,
|
||||
},
|
||||
CoreEventType::IncomingCallAccepted { msg_id, chat_id } => IncomingCallAccepted {
|
||||
CoreEventType::IncomingCallAccepted {
|
||||
msg_id,
|
||||
chat_id,
|
||||
from_this_device,
|
||||
} => IncomingCallAccepted {
|
||||
msg_id: msg_id.to_u32(),
|
||||
chat_id: chat_id.to_u32(),
|
||||
from_this_device,
|
||||
},
|
||||
CoreEventType::OutgoingCallAccepted {
|
||||
msg_id,
|
||||
@@ -642,6 +658,8 @@ impl From<CoreEventType> for EventType {
|
||||
msg_id: msg_id.to_u32(),
|
||||
chat_id: chat_id.to_u32(),
|
||||
},
|
||||
CoreEventType::TransportsModified => TransportsModified,
|
||||
|
||||
#[allow(unreachable_patterns)]
|
||||
#[cfg(test)]
|
||||
_ => unreachable!("This is just to silence a rust_analyzer false-positive"),
|
||||
|
||||
@@ -23,6 +23,12 @@ pub struct EnteredLoginParam {
|
||||
/// Imap server port.
|
||||
pub imap_port: Option<u16>,
|
||||
|
||||
/// IMAP server folder.
|
||||
///
|
||||
/// Defaults to "INBOX" if not set.
|
||||
/// Should not be an empty string.
|
||||
pub imap_folder: Option<String>,
|
||||
|
||||
/// Imap socket security.
|
||||
pub imap_security: Option<Socket>,
|
||||
|
||||
@@ -66,6 +72,7 @@ impl From<dc::EnteredLoginParam> for EnteredLoginParam {
|
||||
password: param.imap.password,
|
||||
imap_server: param.imap.server.into_option(),
|
||||
imap_port: param.imap.port.into_option(),
|
||||
imap_folder: param.imap.folder.into_option(),
|
||||
imap_security: imap_security.into_option(),
|
||||
imap_user: param.imap.user.into_option(),
|
||||
smtp_server: param.smtp.server.into_option(),
|
||||
@@ -85,14 +92,15 @@ impl TryFrom<EnteredLoginParam> for dc::EnteredLoginParam {
|
||||
fn try_from(param: EnteredLoginParam) -> Result<Self> {
|
||||
Ok(Self {
|
||||
addr: param.addr,
|
||||
imap: dc::EnteredServerLoginParam {
|
||||
imap: dc::EnteredImapLoginParam {
|
||||
server: param.imap_server.unwrap_or_default(),
|
||||
port: param.imap_port.unwrap_or_default(),
|
||||
folder: param.imap_folder.unwrap_or_default(),
|
||||
security: param.imap_security.unwrap_or_default().into(),
|
||||
user: param.imap_user.unwrap_or_default(),
|
||||
password: param.password,
|
||||
},
|
||||
smtp: dc::EnteredServerLoginParam {
|
||||
smtp: dc::EnteredSmtpLoginParam {
|
||||
server: param.smtp_server.unwrap_or_default(),
|
||||
port: param.smtp_port.unwrap_or_default(),
|
||||
security: param.smtp_security.unwrap_or_default().into(),
|
||||
|
||||
@@ -92,6 +92,9 @@ pub struct MessageObject {
|
||||
|
||||
file: Option<String>,
|
||||
file_mime: Option<String>,
|
||||
|
||||
/// The size of the file in bytes, if applicable.
|
||||
/// If message is a pre-message, then this is the size of the file to be downloaded.
|
||||
file_bytes: u64,
|
||||
file_name: Option<String>,
|
||||
|
||||
@@ -385,6 +388,7 @@ impl From<download::DownloadState> for DownloadState {
|
||||
pub enum SystemMessageType {
|
||||
Unknown,
|
||||
GroupNameChanged,
|
||||
GroupDescriptionChanged,
|
||||
GroupImageChanged,
|
||||
MemberAddedToGroup,
|
||||
MemberRemovedFromGroup,
|
||||
@@ -437,6 +441,7 @@ impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
|
||||
match system_message_type {
|
||||
SystemMessage::Unknown => SystemMessageType::Unknown,
|
||||
SystemMessage::GroupNameChanged => SystemMessageType::GroupNameChanged,
|
||||
SystemMessage::GroupDescriptionChanged => SystemMessageType::GroupDescriptionChanged,
|
||||
SystemMessage::GroupImageChanged => SystemMessageType::GroupImageChanged,
|
||||
SystemMessage::MemberAddedToGroup => SystemMessageType::MemberAddedToGroup,
|
||||
SystemMessage::MemberRemovedFromGroup => SystemMessageType::MemberRemovedFromGroup,
|
||||
|
||||
@@ -19,6 +19,8 @@ pub enum QrObject {
|
||||
invitenumber: String,
|
||||
/// Authentication code.
|
||||
authcode: String,
|
||||
/// Whether the inviter supports the new Securejoin v3 protocol
|
||||
is_v3: bool,
|
||||
},
|
||||
/// Ask the user whether to join the group.
|
||||
AskVerifyGroup {
|
||||
@@ -34,6 +36,8 @@ pub enum QrObject {
|
||||
invitenumber: String,
|
||||
/// Authentication code.
|
||||
authcode: String,
|
||||
/// Whether the inviter supports the new Securejoin v3 protocol
|
||||
is_v3: bool,
|
||||
},
|
||||
/// Ask the user whether to join the broadcast channel.
|
||||
AskJoinBroadcast {
|
||||
@@ -54,6 +58,8 @@ pub enum QrObject {
|
||||
invitenumber: String,
|
||||
/// Authentication code.
|
||||
authcode: String,
|
||||
/// Whether the inviter supports the new Securejoin v3 protocol
|
||||
is_v3: bool,
|
||||
},
|
||||
/// Contact fingerprint is verified.
|
||||
///
|
||||
@@ -229,6 +235,7 @@ impl From<Qr> for QrObject {
|
||||
fingerprint,
|
||||
invitenumber,
|
||||
authcode,
|
||||
is_v3,
|
||||
} => {
|
||||
let contact_id = contact_id.to_u32();
|
||||
let fingerprint = fingerprint.to_string();
|
||||
@@ -237,6 +244,7 @@ impl From<Qr> for QrObject {
|
||||
fingerprint,
|
||||
invitenumber,
|
||||
authcode,
|
||||
is_v3,
|
||||
}
|
||||
}
|
||||
Qr::AskVerifyGroup {
|
||||
@@ -246,6 +254,7 @@ impl From<Qr> for QrObject {
|
||||
fingerprint,
|
||||
invitenumber,
|
||||
authcode,
|
||||
is_v3,
|
||||
} => {
|
||||
let contact_id = contact_id.to_u32();
|
||||
let fingerprint = fingerprint.to_string();
|
||||
@@ -256,6 +265,7 @@ impl From<Qr> for QrObject {
|
||||
fingerprint,
|
||||
invitenumber,
|
||||
authcode,
|
||||
is_v3,
|
||||
}
|
||||
}
|
||||
Qr::AskJoinBroadcast {
|
||||
@@ -265,6 +275,7 @@ impl From<Qr> for QrObject {
|
||||
fingerprint,
|
||||
authcode,
|
||||
invitenumber,
|
||||
is_v3,
|
||||
} => {
|
||||
let contact_id = contact_id.to_u32();
|
||||
let fingerprint = fingerprint.to_string();
|
||||
@@ -275,6 +286,7 @@ impl From<Qr> for QrObject {
|
||||
fingerprint,
|
||||
authcode,
|
||||
invitenumber,
|
||||
is_v3,
|
||||
}
|
||||
}
|
||||
Qr::FprOk { contact_id } => {
|
||||
|
||||
@@ -54,5 +54,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "dist/deltachat.d.ts",
|
||||
"version": "2.27.0"
|
||||
"version": "2.46.0-dev"
|
||||
}
|
||||
|
||||
@@ -53,18 +53,19 @@ export class BaseDeltaChat<
|
||||
*/
|
||||
async eventLoop(): Promise<void> {
|
||||
while (true) {
|
||||
const event = await this.rpc.getNextEvent();
|
||||
//@ts-ignore
|
||||
this.emit(event.event.kind, event.contextId, event.event);
|
||||
this.emit("ALL", event.contextId, event.event);
|
||||
for (const event of await this.rpc.getNextEventBatch()) {
|
||||
//@ts-ignore
|
||||
this.emit(event.event.kind, event.contextId, event.event);
|
||||
this.emit("ALL", event.contextId, event.event);
|
||||
|
||||
if (this.contextEmitters[event.contextId]) {
|
||||
this.contextEmitters[event.contextId].emit(
|
||||
event.event.kind,
|
||||
//@ts-ignore
|
||||
event.event as any,
|
||||
);
|
||||
this.contextEmitters[event.contextId].emit("ALL", event.event as any);
|
||||
if (this.contextEmitters[event.contextId]) {
|
||||
this.contextEmitters[event.contextId].emit(
|
||||
event.event.kind,
|
||||
//@ts-ignore
|
||||
event.event as any,
|
||||
);
|
||||
this.contextEmitters[event.contextId].emit("ALL", event.event as any);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ describe("online tests", function () {
|
||||
await dc.rpc.setConfig(accountId1, "addr", account1.email);
|
||||
await dc.rpc.setConfig(accountId1, "mail_pw", account1.password);
|
||||
await dc.rpc.configure(accountId1);
|
||||
await waitForEvent(dc, "ImapInboxIdle", accountId1);
|
||||
|
||||
accountId2 = await dc.rpc.addAccount();
|
||||
await dc.rpc.batchSetConfig(accountId2, {
|
||||
@@ -71,6 +72,7 @@ describe("online tests", function () {
|
||||
mail_pw: account2.password,
|
||||
});
|
||||
await dc.rpc.configure(accountId2);
|
||||
await waitForEvent(dc, "ImapInboxIdle", accountId2);
|
||||
accountsConfigured = true;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-repl"
|
||||
version = "2.27.0"
|
||||
version = "2.46.0-dev"
|
||||
license = "MPL-2.0"
|
||||
edition = "2021"
|
||||
repository = "https://github.com/chatmail/core"
|
||||
|
||||
@@ -343,6 +343,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
|
||||
addmember <contact-id>\n\
|
||||
removemember <contact-id>\n\
|
||||
groupname <name>\n\
|
||||
groupdescription <description>\n\
|
||||
groupimage <image>\n\
|
||||
chatinfo\n\
|
||||
sendlocations <seconds>\n\
|
||||
@@ -770,6 +771,13 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
|
||||
|
||||
println!("Chat name set");
|
||||
}
|
||||
"groupdescription" => {
|
||||
ensure!(sel_chat.is_some(), "No chat selected.");
|
||||
ensure!(!arg1.is_empty(), "Argument <description> missing.");
|
||||
chat::set_chat_description(&context, sel_chat.as_ref().unwrap().get_id(), arg1).await?;
|
||||
|
||||
println!("Chat description set");
|
||||
}
|
||||
"groupimage" => {
|
||||
ensure!(sel_chat.is_some(), "No chat selected.");
|
||||
ensure!(!arg1.is_empty(), "Argument <image> missing.");
|
||||
@@ -1231,7 +1239,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
|
||||
"setqr" => {
|
||||
ensure!(!arg1.is_empty(), "Argument <qr-content> missing.");
|
||||
match set_config_from_qr(&context, arg1).await {
|
||||
Ok(()) => println!("Config set from QR code, you can now call 'configure'"),
|
||||
Ok(()) => eprintln!("Config set from the QR code."),
|
||||
Err(err) => eprintln!("Cannot set config from QR code: {err:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ const DB_COMMANDS: [&str; 11] = [
|
||||
"housekeeping",
|
||||
];
|
||||
|
||||
const CHAT_COMMANDS: [&str; 39] = [
|
||||
const CHAT_COMMANDS: [&str; 40] = [
|
||||
"listchats",
|
||||
"listarchived",
|
||||
"start-realtime",
|
||||
@@ -192,6 +192,7 @@ const CHAT_COMMANDS: [&str; 39] = [
|
||||
"addmember",
|
||||
"removemember",
|
||||
"groupname",
|
||||
"groupdescription",
|
||||
"groupimage",
|
||||
"chatinfo",
|
||||
"sendlocations",
|
||||
@@ -430,12 +431,12 @@ async fn handle_cmd(
|
||||
}
|
||||
"oauth2" => {
|
||||
if let Some(addr) = ctx.get_config(config::Config::Addr).await? {
|
||||
let oauth2_url =
|
||||
get_oauth2_url(&ctx, &addr, "chat.delta:/com.b44t.messenger").await?;
|
||||
if oauth2_url.is_none() {
|
||||
println!("OAuth2 not available for {}.", &addr);
|
||||
if let Some(oauth2_url) =
|
||||
get_oauth2_url(&ctx, &addr, "chat.delta:/com.b44t.messenger").await?
|
||||
{
|
||||
println!("Open the following url, set mail_pw to the generated token and server_flags to 2:\n{oauth2_url}");
|
||||
} else {
|
||||
println!("Open the following url, set mail_pw to the generated token and server_flags to 2:\n{}", oauth2_url.unwrap());
|
||||
println!("OAuth2 not available for {}.", &addr);
|
||||
}
|
||||
} else {
|
||||
println!("oauth2: set addr first.");
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
RPC client connects to standalone Delta Chat RPC server `deltachat-rpc-server`
|
||||
and provides asynchronous interface to it.
|
||||
`rpc.start()` performs a health-check RPC call to verify the server
|
||||
started successfully and will raise an error if startup fails
|
||||
(e.g. if the accounts directory could not be used).
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -30,6 +33,15 @@ $ pip install .
|
||||
|
||||
Additional arguments to `tox` are passed to pytest, e.g. `tox -- -s` does not capture test output.
|
||||
|
||||
|
||||
## Activating current checkout of deltachat-rpc-client and -server for development
|
||||
|
||||
Go to root repository directory and run:
|
||||
```
|
||||
$ scripts/make-rpc-testenv.sh
|
||||
$ source venv/bin/activate
|
||||
```
|
||||
|
||||
## Using in REPL
|
||||
|
||||
Setup a development environment:
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=45"]
|
||||
requires = ["setuptools>=77"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "deltachat-rpc-client"
|
||||
version = "2.27.0"
|
||||
version = "2.46.0-dev"
|
||||
license = "MPL-2.0"
|
||||
description = "Python client for Delta Chat core JSON-RPC interface"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Operating System :: MacOS :: MacOS X",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
@@ -24,7 +22,7 @@ classifiers = [
|
||||
"Topic :: Communications :: Email"
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
deltachat_rpc_client = [
|
||||
|
||||
@@ -8,7 +8,7 @@ from .const import EventType, SpecialContactId
|
||||
from .contact import Contact
|
||||
from .deltachat import DeltaChat
|
||||
from .message import Message
|
||||
from .rpc import Rpc
|
||||
from .rpc import JsonRpcError, Rpc
|
||||
|
||||
__all__ = [
|
||||
"Account",
|
||||
@@ -19,6 +19,7 @@ __all__ = [
|
||||
"Contact",
|
||||
"DeltaChat",
|
||||
"EventType",
|
||||
"JsonRpcError",
|
||||
"Message",
|
||||
"SpecialContactId",
|
||||
"Rpc",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import argparse
|
||||
import functools
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
@@ -44,8 +45,13 @@ class AttrDict(dict):
|
||||
super().__setattr__(attr, val)
|
||||
|
||||
|
||||
def _forever(_event: AttrDict) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def run_client_cli(
|
||||
hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None,
|
||||
until: Callable[[AttrDict], bool] = _forever,
|
||||
argv: Optional[list] = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
@@ -55,10 +61,11 @@ def run_client_cli(
|
||||
"""
|
||||
from .client import Client
|
||||
|
||||
_run_cli(Client, hooks, argv, **kwargs)
|
||||
_run_cli(Client, until, hooks, argv, **kwargs)
|
||||
|
||||
|
||||
def run_bot_cli(
|
||||
until: Callable[[AttrDict], bool] = _forever,
|
||||
hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None,
|
||||
argv: Optional[list] = None,
|
||||
**kwargs,
|
||||
@@ -69,11 +76,12 @@ def run_bot_cli(
|
||||
"""
|
||||
from .client import Bot
|
||||
|
||||
_run_cli(Bot, hooks, argv, **kwargs)
|
||||
_run_cli(Bot, until, hooks, argv, **kwargs)
|
||||
|
||||
|
||||
def _run_cli(
|
||||
client_type: Type["Client"],
|
||||
until: Callable[[AttrDict], bool] = _forever,
|
||||
hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None,
|
||||
argv: Optional[list] = None,
|
||||
**kwargs,
|
||||
@@ -111,7 +119,7 @@ def _run_cli(
|
||||
kwargs={"email": args.email, "password": args.password},
|
||||
)
|
||||
configure_thread.start()
|
||||
client.run_forever()
|
||||
client.run_until(until)
|
||||
|
||||
|
||||
def extract_addr(text: str) -> str:
|
||||
@@ -179,6 +187,7 @@ class futuremethod: # noqa: N801
|
||||
"""Decorator for async methods."""
|
||||
|
||||
def __init__(self, func):
|
||||
functools.update_wrapper(self, func)
|
||||
self._func = func
|
||||
|
||||
def __get__(self, instance, owner=None):
|
||||
|
||||
@@ -130,6 +130,10 @@ class Account:
|
||||
"""Add a new transport using a QR code."""
|
||||
yield self._rpc.add_transport_from_qr.future(self.id, qr)
|
||||
|
||||
def delete_transport(self, addr: str):
|
||||
"""Delete a transport."""
|
||||
self._rpc.delete_transport(self.id, addr)
|
||||
|
||||
@futuremethod
|
||||
def list_transports(self):
|
||||
"""Return the list of all email accounts that are used as a transport in the current profile."""
|
||||
|
||||
@@ -219,10 +219,12 @@ class Chat:
|
||||
"""Mark all messages in this chat as noticed."""
|
||||
self._rpc.marknoticed_chat(self.account.id, self.id)
|
||||
|
||||
def add_contact(self, *contact: Union[int, str, Contact]) -> None:
|
||||
def add_contact(self, *contact: Union[int, str, Contact, "Account"]) -> None:
|
||||
"""Add contacts to this group."""
|
||||
from .account import Account
|
||||
|
||||
for cnt in contact:
|
||||
if isinstance(cnt, str):
|
||||
if isinstance(cnt, (str, Account)):
|
||||
contact_id = self.account.create_contact(cnt).id
|
||||
elif not isinstance(cnt, int):
|
||||
contact_id = cnt.id
|
||||
@@ -230,10 +232,12 @@ class Chat:
|
||||
contact_id = cnt
|
||||
self._rpc.add_contact_to_chat(self.account.id, self.id, contact_id)
|
||||
|
||||
def remove_contact(self, *contact: Union[int, str, Contact]) -> None:
|
||||
def remove_contact(self, *contact: Union[int, str, Contact, "Account"]) -> None:
|
||||
"""Remove members from this group."""
|
||||
from .account import Account
|
||||
|
||||
for cnt in contact:
|
||||
if isinstance(cnt, str):
|
||||
if isinstance(cnt, (str, Account)):
|
||||
contact_id = self.account.create_contact(cnt).id
|
||||
elif not isinstance(cnt, int):
|
||||
contact_id = cnt.id
|
||||
@@ -249,6 +253,10 @@ class Chat:
|
||||
contacts = self._rpc.get_chat_contacts(self.account.id, self.id)
|
||||
return [Contact(self.account, contact_id) for contact_id in contacts]
|
||||
|
||||
def num_contacts(self) -> int:
|
||||
"""Return number of contacts in this chat."""
|
||||
return len(self.get_contacts())
|
||||
|
||||
def get_past_contacts(self) -> list[Contact]:
|
||||
"""Get past contacts for this chat."""
|
||||
past_contacts = self._rpc.get_past_chat_contacts(self.account.id, self.id)
|
||||
@@ -295,7 +303,7 @@ class Chat:
|
||||
f.flush()
|
||||
self._rpc.send_msg(self.account.id, self.id, {"viewtype": ViewType.VCARD, "file": f.name})
|
||||
|
||||
def place_outgoing_call(self, place_call_info: str) -> Message:
|
||||
def place_outgoing_call(self, place_call_info: str, has_video_initially: bool) -> Message:
|
||||
"""Starts an outgoing call."""
|
||||
msg_id = self._rpc.place_outgoing_call(self.account.id, self.id, place_call_info)
|
||||
msg_id = self._rpc.place_outgoing_call(self.account.id, self.id, place_call_info, has_video_initially)
|
||||
return Message(self.account, msg_id)
|
||||
|
||||
@@ -14,6 +14,7 @@ from typing import (
|
||||
|
||||
from ._utils import (
|
||||
AttrDict,
|
||||
_forever,
|
||||
parse_system_add_remove,
|
||||
parse_system_image_changed,
|
||||
parse_system_title_changed,
|
||||
@@ -91,19 +92,28 @@ class Client:
|
||||
|
||||
def run_forever(self) -> None:
|
||||
"""Process events forever."""
|
||||
self.run_until(lambda _: False)
|
||||
self.run_until(_forever)
|
||||
|
||||
def run_until(self, func: Callable[[AttrDict], bool]) -> AttrDict:
|
||||
"""Process events until the given callable evaluates to True.
|
||||
|
||||
The callable should accept an AttrDict object representing the
|
||||
last processed event. The event is returned when the callable
|
||||
evaluates to True.
|
||||
"""
|
||||
"""Start the event processing loop."""
|
||||
self.logger.debug("Listening to incoming events...")
|
||||
if self.is_configured():
|
||||
self.account.start_io()
|
||||
self._process_messages() # Process old messages.
|
||||
return self._process_events(until_func=func) # Loop over incoming events
|
||||
|
||||
def _process_events(
|
||||
self,
|
||||
until_func: Callable[[AttrDict], bool] = _forever,
|
||||
until_event: EventType = False,
|
||||
) -> AttrDict:
|
||||
"""Process events until the given callable evaluates to True,
|
||||
or until a certain event happens.
|
||||
|
||||
The until_func callable should accept an AttrDict object representing
|
||||
the last processed event. The event is returned when the callable
|
||||
evaluates to True.
|
||||
"""
|
||||
while True:
|
||||
event = self.account.wait_for_event()
|
||||
event["kind"] = EventType(event.kind)
|
||||
@@ -112,10 +122,13 @@ class Client:
|
||||
if event.kind == EventType.INCOMING_MSG:
|
||||
self._process_messages()
|
||||
|
||||
stop = func(event)
|
||||
stop = until_func(event)
|
||||
if stop:
|
||||
return event
|
||||
|
||||
if event.kind == until_event:
|
||||
return event
|
||||
|
||||
def _on_event(self, event: AttrDict, filter_type: Type[EventFilter] = RawEvent) -> None:
|
||||
for hook, evfilter in self._hooks.get(filter_type, []):
|
||||
if evfilter.filter(event):
|
||||
|
||||
@@ -80,6 +80,7 @@ class EventType(str, Enum):
|
||||
CONFIG_SYNCED = "ConfigSynced"
|
||||
WEBXDC_REALTIME_DATA = "WebxdcRealtimeData"
|
||||
WEBXDC_REALTIME_ADVERTISEMENT_RECEIVED = "WebxdcRealtimeAdvertisementReceived"
|
||||
TRANSPORTS_MODIFIED = "TransportsModified"
|
||||
|
||||
|
||||
class ChatId(IntEnum):
|
||||
|
||||
@@ -44,6 +44,14 @@ class Message:
|
||||
read_receipts = self._rpc.get_message_read_receipts(self.account.id, self.id)
|
||||
return [AttrDict(read_receipt) for read_receipt in read_receipts]
|
||||
|
||||
def get_read_receipt_count(self) -> int:
|
||||
"""
|
||||
Returns count of read receipts on message.
|
||||
|
||||
This view count is meant as a feedback measure for the channel owner only.
|
||||
"""
|
||||
return self._rpc.get_message_read_receipt_count(self.account.id, self.id)
|
||||
|
||||
def get_reactions(self) -> Optional[AttrDict]:
|
||||
"""Get message reactions."""
|
||||
reactions = self._rpc.get_message_reactions(self.account.id, self.id)
|
||||
@@ -60,6 +68,10 @@ class Message:
|
||||
"""Mark the message as seen."""
|
||||
self._rpc.markseen_msgs(self.account.id, [self.id])
|
||||
|
||||
def exists(self) -> bool:
|
||||
"""Return True if the message exists."""
|
||||
return bool(self._rpc.get_existing_msg_ids(self.account.id, [self.id]))
|
||||
|
||||
def continue_autocrypt_key_transfer(self, setup_code: str) -> None:
|
||||
"""Continue the Autocrypt Setup Message key transfer.
|
||||
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
import execnet
|
||||
import py
|
||||
import pytest
|
||||
|
||||
@@ -16,10 +22,22 @@ from .rpc import Rpc
|
||||
E2EE_INFO_MSGS = 1
|
||||
"""
|
||||
The number of info messages added to new e2ee chats.
|
||||
Currently this is "End-to-end encryption available".
|
||||
Currently this is "Messages are end-to-end encrypted."
|
||||
"""
|
||||
|
||||
|
||||
def pytest_report_header():
|
||||
for base in os.get_exec_path():
|
||||
fn = pathlib.Path(base).joinpath(base, "deltachat-rpc-server")
|
||||
if fn.exists():
|
||||
proc = subprocess.Popen([str(fn), "--version"], stderr=subprocess.PIPE)
|
||||
proc.wait()
|
||||
version = proc.stderr.read().decode().strip()
|
||||
return f"deltachat-rpc-server: {fn} [{version}]"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class ACFactory:
|
||||
"""Test account factory."""
|
||||
|
||||
@@ -36,16 +54,21 @@ class ACFactory:
|
||||
|
||||
def get_credentials(self) -> (str, str):
|
||||
"""Generate new credentials for chatmail account."""
|
||||
domain = os.getenv("CHATMAIL_DOMAIN")
|
||||
domain = os.environ["CHATMAIL_DOMAIN"]
|
||||
username = "ci-" + "".join(random.choice("2345789acdefghjkmnpqrstuvwxyz") for i in range(6))
|
||||
return f"{username}@{domain}", f"{username}${username}"
|
||||
|
||||
def get_account_qr(self):
|
||||
"""Return "dcaccount:" QR code for testing chatmail relay."""
|
||||
domain = os.environ["CHATMAIL_DOMAIN"]
|
||||
return f"dcaccount:{domain}"
|
||||
|
||||
@futuremethod
|
||||
def new_configured_account(self):
|
||||
"""Create a new configured account."""
|
||||
account = self.get_unconfigured_account()
|
||||
domain = os.getenv("CHATMAIL_DOMAIN")
|
||||
yield account.add_transport_from_qr.future(f"dcaccount:{domain}")
|
||||
qr = self.get_account_qr()
|
||||
yield account.add_transport_from_qr.future(qr)
|
||||
|
||||
assert account.is_configured()
|
||||
return account
|
||||
@@ -77,6 +100,7 @@ class ACFactory:
|
||||
ac_clone = self.get_unconfigured_account()
|
||||
for transport in transports:
|
||||
ac_clone.add_or_update_transport(transport)
|
||||
ac_clone.bring_online()
|
||||
return ac_clone
|
||||
|
||||
def get_accepted_chat(self, ac1: Account, ac2: Account) -> Chat:
|
||||
@@ -181,13 +205,143 @@ def log():
|
||||
|
||||
class Printer:
|
||||
def section(self, msg: str) -> None:
|
||||
print()
|
||||
print("=" * 10, msg, "=" * 10)
|
||||
logging.info("\n%s %s %s", "=" * 10, msg, "=" * 10)
|
||||
|
||||
def step(self, msg: str) -> None:
|
||||
print("-" * 5, "step " + msg, "-" * 5)
|
||||
logging.info("%s step %s %s", "-" * 5, msg, "-" * 5)
|
||||
|
||||
def indent(self, msg: str) -> None:
|
||||
print(" " + msg)
|
||||
logging.info(" " + msg)
|
||||
|
||||
return Printer()
|
||||
|
||||
|
||||
#
|
||||
# support for testing against different deltachat-rpc-server/clients
|
||||
# installed into a temporary virtualenv and connected via 'execnet' channels
|
||||
#
|
||||
|
||||
|
||||
def find_path(venv, name):
|
||||
is_windows = platform.system() == "Windows"
|
||||
bin = venv / ("bin" if not is_windows else "Scripts")
|
||||
|
||||
tryadd = [""]
|
||||
if is_windows:
|
||||
tryadd += os.environ["PATHEXT"].split(os.pathsep)
|
||||
for ext in tryadd:
|
||||
p = bin.joinpath(name + ext)
|
||||
if p.exists():
|
||||
return str(p)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def get_core_python_env(tmp_path_factory):
|
||||
"""Return a factory to create virtualenv environments with rpc server/client packages
|
||||
installed.
|
||||
|
||||
The factory takes a version and returns a (python_path, rpc_server_path) tuple
|
||||
of the respective binaries in the virtualenv.
|
||||
"""
|
||||
|
||||
envs = {}
|
||||
|
||||
def get_versioned_venv(core_version):
|
||||
venv = envs.get(core_version)
|
||||
if not venv:
|
||||
venv = tmp_path_factory.mktemp(f"temp-{core_version}")
|
||||
subprocess.check_call([sys.executable, "-m", "venv", venv])
|
||||
|
||||
python = find_path(venv, "python")
|
||||
pkgs = [f"deltachat-rpc-server=={core_version}", f"deltachat-rpc-client=={core_version}", "pytest"]
|
||||
subprocess.check_call([python, "-m", "pip", "install"] + pkgs)
|
||||
|
||||
envs[core_version] = venv
|
||||
python = find_path(venv, "python")
|
||||
rpc_server_path = find_path(venv, "deltachat-rpc-server")
|
||||
logging.info(f"Paths:\npython={python}\nrpc_server={rpc_server_path}")
|
||||
return python, rpc_server_path
|
||||
|
||||
return get_versioned_venv
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def alice_and_remote_bob(tmp_path, acfactory, get_core_python_env):
|
||||
"""return local Alice account, a contact to bob, and a remote 'eval' function for bob.
|
||||
|
||||
The 'eval' function allows to remote-execute arbitrary expressions
|
||||
that can use the `bob` online account, and the `bob_contact_alice`.
|
||||
"""
|
||||
|
||||
def factory(core_version):
|
||||
python, rpc_server_path = get_core_python_env(core_version)
|
||||
gw = execnet.makegateway(f"popen//python={python}")
|
||||
|
||||
accounts_dir = str(tmp_path.joinpath("account1_venv1"))
|
||||
channel = gw.remote_exec(remote_bob_loop)
|
||||
cm = os.environ.get("CHATMAIL_DOMAIN")
|
||||
|
||||
# trigger getting an online account on bob's side
|
||||
channel.send((accounts_dir, str(rpc_server_path), cm))
|
||||
|
||||
# meanwhile get a local alice account
|
||||
alice = acfactory.get_online_account()
|
||||
channel.send(alice.self_contact.make_vcard())
|
||||
|
||||
# wait for bob to have started
|
||||
sysinfo = channel.receive()
|
||||
assert sysinfo == f"v{core_version}"
|
||||
bob_vcard = channel.receive()
|
||||
[alice_contact_bob] = alice.import_vcard(bob_vcard)
|
||||
|
||||
def eval(eval_str):
|
||||
channel.send(eval_str)
|
||||
return channel.receive()
|
||||
|
||||
return alice, alice_contact_bob, eval
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
def remote_bob_loop(channel):
|
||||
# This function executes with versioned
|
||||
# deltachat-rpc-client/server packages
|
||||
# installed into the virtualenv.
|
||||
#
|
||||
# The "channel" argument is a send/receive pipe
|
||||
# to the process that runs the corresponding remote_exec(remote_bob_loop)
|
||||
|
||||
import os
|
||||
|
||||
from deltachat_rpc_client import DeltaChat, Rpc
|
||||
from deltachat_rpc_client.pytestplugin import ACFactory
|
||||
|
||||
accounts_dir, rpc_server_path, chatmail_domain = channel.receive()
|
||||
os.environ["CHATMAIL_DOMAIN"] = chatmail_domain
|
||||
|
||||
# older core versions don't support specifying rpc_server_path
|
||||
# so we can't just pass `rpc_server_path` argument to Rpc constructor
|
||||
basepath = os.path.dirname(rpc_server_path)
|
||||
os.environ["PATH"] = os.pathsep.join([basepath, os.environ["PATH"]])
|
||||
rpc = Rpc(accounts_dir=accounts_dir)
|
||||
|
||||
with rpc:
|
||||
dc = DeltaChat(rpc)
|
||||
channel.send(dc.rpc.get_system_info()["deltachat_core_version"])
|
||||
acfactory = ACFactory(dc)
|
||||
bob = acfactory.get_online_account()
|
||||
alice_vcard = channel.receive()
|
||||
[alice_contact] = bob.import_vcard(alice_vcard)
|
||||
ns = {"bob": bob, "bob_contact_alice": alice_contact}
|
||||
channel.send(bob.self_contact.make_vcard())
|
||||
|
||||
while 1:
|
||||
eval_str = channel.receive()
|
||||
res = eval(eval_str, ns)
|
||||
try:
|
||||
channel.send(res)
|
||||
except Exception:
|
||||
# some unserializable result
|
||||
channel.send(None)
|
||||
|
||||
@@ -9,7 +9,7 @@ import os
|
||||
import subprocess
|
||||
import sys
|
||||
from queue import Empty, Queue
|
||||
from threading import Event, Thread
|
||||
from threading import Thread
|
||||
from typing import Any, Iterator, Optional
|
||||
|
||||
|
||||
@@ -17,25 +17,6 @@ class JsonRpcError(Exception):
|
||||
"""JSON-RPC error."""
|
||||
|
||||
|
||||
class RpcFuture:
|
||||
"""RPC future waiting for RPC call result."""
|
||||
|
||||
def __init__(self, rpc: "Rpc", request_id: int, event: Event):
|
||||
self.rpc = rpc
|
||||
self.request_id = request_id
|
||||
self.event = event
|
||||
|
||||
def __call__(self):
|
||||
"""Wait for the future to return the result."""
|
||||
self.event.wait()
|
||||
response = self.rpc.request_results.pop(self.request_id)
|
||||
if "error" in response:
|
||||
raise JsonRpcError(response["error"])
|
||||
if "result" in response:
|
||||
return response["result"]
|
||||
return None
|
||||
|
||||
|
||||
class RpcMethod:
|
||||
"""RPC method."""
|
||||
|
||||
@@ -57,20 +38,31 @@ class RpcMethod:
|
||||
"params": args,
|
||||
"id": request_id,
|
||||
}
|
||||
event = Event()
|
||||
self.rpc.request_events[request_id] = event
|
||||
self.rpc.request_results[request_id] = queue = Queue()
|
||||
self.rpc.request_queue.put(request)
|
||||
|
||||
return RpcFuture(self.rpc, request_id, event)
|
||||
def rpc_future():
|
||||
"""Wait for the request to receive a result."""
|
||||
response = queue.get()
|
||||
if "error" in response:
|
||||
raise JsonRpcError(response["error"])
|
||||
return response.get("result", None)
|
||||
|
||||
return rpc_future
|
||||
|
||||
|
||||
class Rpc:
|
||||
"""RPC client."""
|
||||
|
||||
def __init__(self, accounts_dir: Optional[str] = None, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
accounts_dir: Optional[str] = None,
|
||||
rpc_server_path="deltachat-rpc-server",
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize RPC client.
|
||||
|
||||
The given arguments will be passed to subprocess.Popen().
|
||||
The 'kwargs' arguments will be passed to subprocess.Popen().
|
||||
"""
|
||||
if accounts_dir:
|
||||
kwargs["env"] = {
|
||||
@@ -79,13 +71,12 @@ class Rpc:
|
||||
}
|
||||
|
||||
self._kwargs = kwargs
|
||||
self.rpc_server_path = rpc_server_path
|
||||
self.process: subprocess.Popen
|
||||
self.id_iterator: Iterator[int]
|
||||
self.event_queues: dict[int, Queue]
|
||||
# Map from request ID to `threading.Event`.
|
||||
self.request_events: dict[int, Event]
|
||||
# Map from request ID to the result.
|
||||
self.request_results: dict[int, Any]
|
||||
# Map from request ID to a Queue which provides a single result
|
||||
self.request_results: dict[int, Queue]
|
||||
self.request_queue: Queue[Any]
|
||||
self.closing: bool
|
||||
self.reader_thread: Thread
|
||||
@@ -93,28 +84,27 @@ class Rpc:
|
||||
self.events_thread: Thread
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start RPC server subprocess."""
|
||||
"""Start RPC server subprocess and wait for successful initialization.
|
||||
|
||||
This method blocks until the RPC server responds to an initial
|
||||
health-check RPC call (get_system_info).
|
||||
If the server fails to start
|
||||
(e.g., due to an invalid accounts directory),
|
||||
a JsonRpcError is raised.
|
||||
"""
|
||||
popen_kwargs = {"stdin": subprocess.PIPE, "stdout": subprocess.PIPE, "stderr": subprocess.PIPE}
|
||||
if sys.version_info >= (3, 11):
|
||||
self.process = subprocess.Popen(
|
||||
"deltachat-rpc-server",
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
# Prevent subprocess from capturing SIGINT.
|
||||
process_group=0,
|
||||
**self._kwargs,
|
||||
)
|
||||
# Prevent subprocess from capturing SIGINT.
|
||||
popen_kwargs["process_group"] = 0
|
||||
else:
|
||||
self.process = subprocess.Popen(
|
||||
"deltachat-rpc-server",
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
# `process_group` is not supported before Python 3.11.
|
||||
preexec_fn=os.setpgrp, # noqa: PLW1509
|
||||
**self._kwargs,
|
||||
)
|
||||
# `process_group` is not supported before Python 3.11.
|
||||
popen_kwargs["preexec_fn"] = os.setpgrp # noqa: PLW1509
|
||||
|
||||
popen_kwargs.update(self._kwargs)
|
||||
self.process = subprocess.Popen(self.rpc_server_path, **popen_kwargs)
|
||||
|
||||
self.id_iterator = itertools.count(start=1)
|
||||
self.event_queues = {}
|
||||
self.request_events = {}
|
||||
self.request_results = {}
|
||||
self.request_queue = Queue()
|
||||
self.closing = False
|
||||
@@ -125,6 +115,22 @@ class Rpc:
|
||||
self.events_thread = Thread(target=self.events_loop)
|
||||
self.events_thread.start()
|
||||
|
||||
# Perform a health-check RPC call to ensure the server started
|
||||
# successfully and the accounts directory is usable.
|
||||
try:
|
||||
system_info = self.get_system_info()
|
||||
except (JsonRpcError, Exception) as e:
|
||||
# The reader_loop already saw EOF on stdout, so the process
|
||||
# has exited and stderr is available.
|
||||
stderr = self.process.stderr.read().decode(errors="replace").strip()
|
||||
if stderr:
|
||||
raise JsonRpcError(f"RPC server failed to start: {stderr}") from e
|
||||
raise JsonRpcError(f"RPC server startup check failed: {e}") from e
|
||||
logging.info(
|
||||
"RPC server ready. Core version: %s",
|
||||
system_info.get("deltachat_core_version", "unknown"),
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Terminate RPC server process and wait until the reader loop finishes."""
|
||||
self.closing = True
|
||||
@@ -149,14 +155,16 @@ class Rpc:
|
||||
response = json.loads(line)
|
||||
if "id" in response:
|
||||
response_id = response["id"]
|
||||
event = self.request_events.pop(response_id)
|
||||
self.request_results[response_id] = response
|
||||
event.set()
|
||||
self.request_results.pop(response_id).put(response)
|
||||
else:
|
||||
logging.warning("Got a response without ID: %s", response)
|
||||
except Exception:
|
||||
# Log an exception if the reader loop dies.
|
||||
logging.exception("Exception in the reader loop")
|
||||
finally:
|
||||
# Unblock any pending requests when the server closes stdout.
|
||||
for _request_id, queue in self.request_results.items():
|
||||
queue.put({"error": {"code": -32000, "message": "RPC server closed"}})
|
||||
|
||||
def writer_loop(self) -> None:
|
||||
"""Writer loop ensuring only a single thread writes requests."""
|
||||
@@ -165,7 +173,6 @@ class Rpc:
|
||||
data = (json.dumps(request) + "\n").encode()
|
||||
self.process.stdin.write(data)
|
||||
self.process.stdin.flush()
|
||||
|
||||
except Exception:
|
||||
# Log an exception if the writer loop dies.
|
||||
logging.exception("Exception in the writer loop")
|
||||
@@ -179,15 +186,15 @@ class Rpc:
|
||||
def events_loop(self) -> None:
|
||||
"""Request new events and distributes them between queues."""
|
||||
try:
|
||||
while True:
|
||||
while events := self.get_next_event_batch():
|
||||
for event in events:
|
||||
account_id = event["contextId"]
|
||||
queue = self.get_queue(account_id)
|
||||
payload = event["event"]
|
||||
logging.debug("account_id=%d got an event %s", account_id, payload)
|
||||
queue.put(payload)
|
||||
if self.closing:
|
||||
return
|
||||
event = self.get_next_event()
|
||||
account_id = event["contextId"]
|
||||
queue = self.get_queue(account_id)
|
||||
event = event["event"]
|
||||
logging.debug("account_id=%d got an event %s", account_id, event)
|
||||
queue.put(event)
|
||||
except Exception:
|
||||
# Log an exception if the event loop dies.
|
||||
logging.exception("Exception in the event loop")
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import imaplib
|
||||
import io
|
||||
import logging
|
||||
import pathlib
|
||||
import ssl
|
||||
from contextlib import contextmanager
|
||||
@@ -45,13 +46,13 @@ class DirectImap:
|
||||
try:
|
||||
self.conn.logout()
|
||||
except (OSError, imaplib.IMAP4.abort):
|
||||
print("Could not logout direct_imap conn")
|
||||
logging.warning("Could not logout direct_imap conn")
|
||||
|
||||
def create_folder(self, foldername):
|
||||
try:
|
||||
self.conn.folder.create(foldername)
|
||||
except errors.MailboxFolderCreateError as e:
|
||||
print("Can't create", foldername, "probably it already exists:", str(e))
|
||||
logging.warning(f"Cannot create '{foldername}', probably it already exists: {str(e)}")
|
||||
|
||||
def select_folder(self, foldername: str) -> tuple:
|
||||
assert not self._idling
|
||||
@@ -95,7 +96,7 @@ class DirectImap:
|
||||
messages = self.get_unread_messages()
|
||||
if messages:
|
||||
res = self.conn.flag(messages, MailMessageFlags.SEEN, True)
|
||||
print("marked seen:", messages, res)
|
||||
logging.info(f"Marked seen: {messages} {res}")
|
||||
|
||||
def get_unread_cnt(self) -> int:
|
||||
return len(self.get_unread_messages())
|
||||
|
||||
@@ -10,15 +10,15 @@ def test_calls(acfactory) -> None:
|
||||
alice_contact_bob = alice.create_contact(bob, "Bob")
|
||||
alice_chat_bob = alice_contact_bob.create_chat()
|
||||
bob.create_chat(alice) # Accept the chat so incoming call causes a notification.
|
||||
outgoing_call_message = alice_chat_bob.place_outgoing_call(place_call_info)
|
||||
outgoing_call_message = alice_chat_bob.place_outgoing_call(place_call_info, has_video_initially=True)
|
||||
assert outgoing_call_message.get_call_info().state.kind == "Alerting"
|
||||
|
||||
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
|
||||
assert incoming_call_event.place_call_info == place_call_info
|
||||
assert not incoming_call_event.has_video # Cannot be parsed as SDP, so false by default
|
||||
assert incoming_call_event.has_video
|
||||
incoming_call_message = Message(bob, incoming_call_event.msg_id)
|
||||
assert incoming_call_message.get_call_info().state.kind == "Alerting"
|
||||
assert not incoming_call_message.get_call_info().has_video
|
||||
assert incoming_call_message.get_call_info().has_video
|
||||
|
||||
incoming_call_message.accept_incoming_call(accept_call_info)
|
||||
assert incoming_call_message.get_call_info().sdp_offer == place_call_info
|
||||
@@ -41,46 +41,38 @@ def test_video_call(acfactory) -> None:
|
||||
#
|
||||
# `s=` cannot be empty according to RFC 3264,
|
||||
# so it is more clear as `s=-`.
|
||||
place_call_info = """v=0\r
|
||||
o=alice 2890844526 2890844526 IN IP6 2001:db8::3\r
|
||||
s=-\r
|
||||
c=IN IP6 2001:db8::3\r
|
||||
t=0 0\r
|
||||
a=group:BUNDLE foo bar\r
|
||||
\r
|
||||
m=audio 10000 RTP/AVP 0 8 97\r
|
||||
b=AS:200\r
|
||||
a=mid:foo\r
|
||||
a=rtcp-mux\r
|
||||
a=rtpmap:0 PCMU/8000\r
|
||||
a=rtpmap:8 PCMA/8000\r
|
||||
a=rtpmap:97 iLBC/8000\r
|
||||
a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r
|
||||
\r
|
||||
m=video 10002 RTP/AVP 31 32\r
|
||||
b=AS:1000\r
|
||||
a=mid:bar\r
|
||||
a=rtcp-mux\r
|
||||
a=rtpmap:31 H261/90000\r
|
||||
a=rtpmap:32 MPV/90000\r
|
||||
a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r
|
||||
"""
|
||||
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
bob.create_chat(alice) # Accept the chat so incoming call causes a notification.
|
||||
alice_contact_bob = alice.create_contact(bob, "Bob")
|
||||
alice_chat_bob = alice_contact_bob.create_chat()
|
||||
alice_chat_bob.place_outgoing_call(place_call_info)
|
||||
alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
|
||||
|
||||
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
|
||||
assert incoming_call_event.place_call_info == place_call_info
|
||||
assert incoming_call_event.place_call_info == "offer"
|
||||
assert incoming_call_event.has_video
|
||||
|
||||
incoming_call_message = Message(bob, incoming_call_event.msg_id)
|
||||
assert incoming_call_message.get_call_info().has_video
|
||||
|
||||
|
||||
def test_audio_call(acfactory) -> None:
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
bob.create_chat(alice) # Accept the chat so incoming call causes a notification.
|
||||
alice_contact_bob = alice.create_contact(bob, "Bob")
|
||||
alice_chat_bob = alice_contact_bob.create_chat()
|
||||
alice_chat_bob.place_outgoing_call("offer", has_video_initially=False)
|
||||
|
||||
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
|
||||
assert incoming_call_event.place_call_info == "offer"
|
||||
assert not incoming_call_event.has_video
|
||||
|
||||
incoming_call_message = Message(bob, incoming_call_event.msg_id)
|
||||
assert not incoming_call_message.get_call_info().has_video
|
||||
|
||||
|
||||
def test_ice_servers(acfactory) -> None:
|
||||
alice = acfactory.get_online_account()
|
||||
|
||||
@@ -92,7 +84,7 @@ def test_no_contact_request_call(acfactory) -> None:
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
alice_chat_bob = alice.create_chat(bob)
|
||||
alice_chat_bob.place_outgoing_call("offer")
|
||||
alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
|
||||
alice_chat_bob.send_text("Hello!")
|
||||
|
||||
# Notification for "Hello!" message should arrive
|
||||
@@ -107,3 +99,48 @@ def test_no_contact_request_call(acfactory) -> None:
|
||||
msg = bob.get_message_by_id(event.msg_id)
|
||||
if msg.get_snapshot().text == "Hello!":
|
||||
break
|
||||
|
||||
|
||||
def test_who_can_call_me_nobody(acfactory) -> None:
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
# Bob sets "who can call me" to "nobody" (2)
|
||||
bob.set_config("who_can_call_me", "2")
|
||||
|
||||
# Bob even accepts Alice in advance so the chat does not appear as contact request.
|
||||
bob.create_chat(alice)
|
||||
|
||||
alice_chat_bob = alice.create_chat(bob)
|
||||
alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
|
||||
alice_chat_bob.send_text("Hello!")
|
||||
|
||||
# Notification for "Hello!" message should arrive
|
||||
# without the call ringing.
|
||||
while True:
|
||||
event = bob.wait_for_event()
|
||||
|
||||
# There should be no incoming call notification.
|
||||
assert event.kind != EventType.INCOMING_CALL
|
||||
|
||||
if event.kind == EventType.INCOMING_MSG:
|
||||
msg = bob.get_message_by_id(event.msg_id)
|
||||
if msg.get_snapshot().text == "Hello!":
|
||||
break
|
||||
|
||||
|
||||
def test_who_can_call_me_everybody(acfactory) -> None:
|
||||
"""Test that if "who can call me" setting is set to "everybody", calls arrive even in contact request chats."""
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
# Bob sets "who can call me" to "nobody" (0)
|
||||
bob.set_config("who_can_call_me", "0")
|
||||
|
||||
alice_chat_bob = alice.create_chat(bob)
|
||||
alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
|
||||
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
|
||||
|
||||
incoming_call_message = Message(bob, incoming_call_event.msg_id)
|
||||
|
||||
# Even with the call arriving, the chat is still in the contact request mode.
|
||||
incoming_chat = incoming_call_message.get_snapshot().chat
|
||||
assert incoming_chat.get_basic_snapshot().is_contact_request
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from deltachat_rpc_client import Account, EventType, const
|
||||
@@ -129,7 +127,7 @@ def test_download_on_demand(acfactory: ACFactory) -> None:
|
||||
msg.get_snapshot().chat.accept()
|
||||
bob.get_chat_by_id(chat_id).send_message(
|
||||
"Hello World, this message is bigger than 5 bytes",
|
||||
html=base64.b64encode(os.urandom(300000)).decode("utf-8"),
|
||||
file="../test-data/image/screenshot.jpg",
|
||||
)
|
||||
|
||||
message = alice.wait_for_incoming_msg()
|
||||
|
||||
57
deltachat-rpc-client/tests/test_cross_core.py
Normal file
57
deltachat-rpc-client/tests/test_cross_core.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from deltachat_rpc_client import DeltaChat, Rpc
|
||||
|
||||
|
||||
def test_install_venv_and_use_other_core(tmp_path, get_core_python_env):
|
||||
python, rpc_server_path = get_core_python_env("2.24.0")
|
||||
subprocess.check_call([python, "-m", "pip", "install", "deltachat-rpc-server==2.24.0"])
|
||||
rpc = Rpc(accounts_dir=tmp_path.joinpath("accounts"), rpc_server_path=rpc_server_path)
|
||||
|
||||
with rpc:
|
||||
dc = DeltaChat(rpc)
|
||||
assert dc.rpc.get_system_info()["deltachat_core_version"] == "v2.24.0"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", ["2.24.0"])
|
||||
def test_qr_setup_contact(alice_and_remote_bob, version) -> None:
|
||||
"""Test other-core Bob profile can do securejoin with Alice on current core."""
|
||||
alice, alice_contact_bob, remote_eval = alice_and_remote_bob(version)
|
||||
|
||||
qr_code = alice.get_qr_code()
|
||||
remote_eval(f"bob.secure_join({qr_code!r})")
|
||||
alice.wait_for_securejoin_inviter_success()
|
||||
|
||||
# Test that Alice verified Bob's profile.
|
||||
alice_contact_bob_snapshot = alice_contact_bob.get_snapshot()
|
||||
assert alice_contact_bob_snapshot.is_verified
|
||||
|
||||
remote_eval("bob.wait_for_securejoin_joiner_success()")
|
||||
|
||||
# Test that Bob verified Alice's profile.
|
||||
assert remote_eval("bob_contact_alice.get_snapshot().is_verified")
|
||||
|
||||
|
||||
def test_send_and_receive_message(alice_and_remote_bob) -> None:
|
||||
"""Test other-core Bob profile can send a message to Alice on current core."""
|
||||
alice, alice_contact_bob, remote_eval = alice_and_remote_bob("2.20.0")
|
||||
|
||||
remote_eval("bob_contact_alice.create_chat().send_text('hello')")
|
||||
|
||||
msg = alice.wait_for_incoming_msg()
|
||||
assert msg.get_snapshot().text == "hello"
|
||||
|
||||
|
||||
def test_second_device(acfactory, alice_and_remote_bob) -> None:
|
||||
"""Test setting up current version as a second device for old version."""
|
||||
_alice, alice_contact_bob, remote_eval = alice_and_remote_bob("2.20.0")
|
||||
|
||||
remote_eval("locals().setdefault('future', bob._rpc.provide_backup.future(bob.id))")
|
||||
qr = remote_eval("bob._rpc.get_backup_qr(bob.id)")
|
||||
new_account = acfactory.get_unconfigured_account()
|
||||
new_account._rpc.get_backup(new_account.id, qr)
|
||||
remote_eval("locals()['future']()")
|
||||
|
||||
assert new_account.get_config("addr") == remote_eval("bob.get_config('addr')")
|
||||
@@ -2,92 +2,13 @@ import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from imap_tools import AND, U
|
||||
|
||||
from deltachat_rpc_client import Contact, EventType, Message
|
||||
|
||||
|
||||
def test_move_works(acfactory):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
ac2.set_config("mvbox_move", "1")
|
||||
ac2.bring_online()
|
||||
|
||||
chat = ac1.create_chat(ac2)
|
||||
chat.send_text("message1")
|
||||
|
||||
# Message is moved to the movebox
|
||||
ac2.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
|
||||
|
||||
# Message is downloaded
|
||||
msg = ac2.wait_for_incoming_msg().get_snapshot()
|
||||
assert msg.text == "message1"
|
||||
|
||||
|
||||
def test_move_avoids_loop(acfactory, direct_imap):
|
||||
"""Test that the message is only moved from INBOX to DeltaChat.
|
||||
|
||||
This is to avoid busy loop if moved message reappears in the Inbox
|
||||
or some scanned folder later.
|
||||
For example, this happens on servers that alias `INBOX.DeltaChat` to `DeltaChat` folder,
|
||||
so the message moved to `DeltaChat` appears as a new message in the `INBOX.DeltaChat` folder.
|
||||
We do not want to move this message from `INBOX.DeltaChat` to `DeltaChat` again.
|
||||
"""
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
ac2.set_config("mvbox_move", "1")
|
||||
ac2.set_config("delete_server_after", "0")
|
||||
ac2.bring_online()
|
||||
|
||||
# Create INBOX.DeltaChat folder and make sure
|
||||
# it is detected by full folder scan.
|
||||
ac2_direct_imap = direct_imap(ac2)
|
||||
ac2_direct_imap.create_folder("INBOX.DeltaChat")
|
||||
ac2.stop_io()
|
||||
ac2.start_io()
|
||||
|
||||
while True:
|
||||
event = ac2.wait_for_event()
|
||||
# Wait until the end of folder scan.
|
||||
if event.kind == EventType.INFO and "Found folders:" in event.msg:
|
||||
break
|
||||
|
||||
ac1_chat = acfactory.get_accepted_chat(ac1, ac2)
|
||||
ac1_chat.send_text("Message 1")
|
||||
|
||||
# Message is moved to the DeltaChat folder and downloaded.
|
||||
ac2_msg1 = ac2.wait_for_incoming_msg().get_snapshot()
|
||||
assert ac2_msg1.text == "Message 1"
|
||||
|
||||
# Move the message to the INBOX.DeltaChat again.
|
||||
# We assume that test server uses "." as the delimiter.
|
||||
ac2_direct_imap.select_folder("DeltaChat")
|
||||
ac2_direct_imap.conn.move(["*"], "INBOX.DeltaChat")
|
||||
|
||||
ac1_chat.send_text("Message 2")
|
||||
ac2_msg2 = ac2.wait_for_incoming_msg().get_snapshot()
|
||||
assert ac2_msg2.text == "Message 2"
|
||||
|
||||
# Stop and start I/O to trigger folder scan.
|
||||
ac2.stop_io()
|
||||
ac2.start_io()
|
||||
while True:
|
||||
event = ac2.wait_for_event()
|
||||
# Wait until the end of folder scan.
|
||||
if event.kind == EventType.INFO and "Found folders:" in event.msg:
|
||||
break
|
||||
|
||||
# Check that Message 1 is still in the INBOX.DeltaChat folder
|
||||
# and Message 2 is in the DeltaChat folder.
|
||||
ac2_direct_imap.select_folder("INBOX")
|
||||
assert len(ac2_direct_imap.get_all_messages()) == 0
|
||||
ac2_direct_imap.select_folder("DeltaChat")
|
||||
assert len(ac2_direct_imap.get_all_messages()) == 1
|
||||
ac2_direct_imap.select_folder("INBOX.DeltaChat")
|
||||
assert len(ac2_direct_imap.get_all_messages()) == 1
|
||||
|
||||
|
||||
def test_reactions_for_a_reordering_move(acfactory, direct_imap):
|
||||
"""When a batch of messages is moved from Inbox to DeltaChat folder with a single MOVE command,
|
||||
"""When a batch of messages is moved from Inbox to another folder with a single MOVE command,
|
||||
their UIDs may be reordered (e.g. Gmail is known for that) which led to that messages were
|
||||
processed by receive_imf in the wrong order, and, particularly, reactions were processed before
|
||||
messages they refer to and thus dropped.
|
||||
@@ -97,7 +18,6 @@ def test_reactions_for_a_reordering_move(acfactory, direct_imap):
|
||||
addr, password = acfactory.get_credentials()
|
||||
ac2 = acfactory.get_unconfigured_account()
|
||||
ac2.add_or_update_transport({"addr": addr, "password": password})
|
||||
ac2.set_config("mvbox_move", "1")
|
||||
assert ac2.is_configured()
|
||||
|
||||
ac2.bring_online()
|
||||
@@ -113,11 +33,17 @@ def test_reactions_for_a_reordering_move(acfactory, direct_imap):
|
||||
react_str = "\N{THUMBS UP SIGN}"
|
||||
msg1.send_reaction(react_str).wait_until_delivered()
|
||||
|
||||
logging.info("moving messages to ac2's DeltaChat folder in the reverse order")
|
||||
logging.info("moving messages to ac2's movebox folder in the reverse order")
|
||||
ac2_direct_imap = direct_imap(ac2)
|
||||
ac2_direct_imap.create_folder("Movebox")
|
||||
ac2_direct_imap.connect()
|
||||
for uid in sorted([m.uid for m in ac2_direct_imap.get_all_messages()], reverse=True):
|
||||
ac2_direct_imap.conn.move(uid, "DeltaChat")
|
||||
ac2_direct_imap.conn.move(uid, "Movebox")
|
||||
|
||||
logging.info("moving messages back")
|
||||
ac2_direct_imap.select_folder("Movebox")
|
||||
for uid in sorted([m.uid for m in ac2_direct_imap.get_all_messages()]):
|
||||
ac2_direct_imap.conn.move(uid, "INBOX")
|
||||
|
||||
logging.info("receiving messages by ac2")
|
||||
ac2.start_io()
|
||||
@@ -130,193 +56,22 @@ def test_reactions_for_a_reordering_move(acfactory, direct_imap):
|
||||
assert list(reactions.reactions_by_contact.values())[0] == [react_str]
|
||||
|
||||
|
||||
def test_delete_deltachat_folder(acfactory, direct_imap):
|
||||
"""Test that DeltaChat folder is recreated if user deletes it manually."""
|
||||
ac1 = acfactory.new_configured_account()
|
||||
ac1.set_config("mvbox_move", "1")
|
||||
ac1.bring_online()
|
||||
|
||||
ac1_direct_imap = direct_imap(ac1)
|
||||
ac1_direct_imap.conn.folder.delete("DeltaChat")
|
||||
assert "DeltaChat" not in ac1_direct_imap.list_folders()
|
||||
|
||||
# Wait until new folder is created and UIDVALIDITY is updated.
|
||||
while True:
|
||||
event = ac1.wait_for_event()
|
||||
if event.kind == EventType.INFO and "uid/validity change folder DeltaChat" in event.msg:
|
||||
break
|
||||
|
||||
ac2 = acfactory.get_online_account()
|
||||
ac2.create_chat(ac1).send_text("hello")
|
||||
msg = ac1.wait_for_incoming_msg().get_snapshot()
|
||||
assert msg.text == "hello"
|
||||
|
||||
assert "DeltaChat" in ac1_direct_imap.list_folders()
|
||||
|
||||
|
||||
def test_dont_show_emails(acfactory, direct_imap, log):
|
||||
"""Most mailboxes have a "Drafts" folder where constantly new emails appear but we don't actually want to show them.
|
||||
So: If it's outgoing AND there is no Received header, then ignore the email.
|
||||
|
||||
If the draft email is sent out and received later (i.e. it's in "Inbox"), it must be shown.
|
||||
|
||||
Also, test that unknown emails in the Spam folder are not shown."""
|
||||
ac1 = acfactory.new_configured_account()
|
||||
ac1.stop_io()
|
||||
ac1.set_config("show_emails", "2")
|
||||
|
||||
ac1.create_contact("alice@example.org").create_chat()
|
||||
|
||||
ac1_direct_imap = direct_imap(ac1)
|
||||
ac1_direct_imap.create_folder("Drafts")
|
||||
ac1_direct_imap.create_folder("Spam")
|
||||
ac1_direct_imap.create_folder("Junk")
|
||||
|
||||
# Learn UID validity for all folders.
|
||||
ac1.set_config("scan_all_folders_debounce_secs", "0")
|
||||
ac1.start_io()
|
||||
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
|
||||
ac1.stop_io()
|
||||
|
||||
ac1_direct_imap.append(
|
||||
"Drafts",
|
||||
"""
|
||||
From: ac1 <{}>
|
||||
Subject: subj
|
||||
To: alice@example.org
|
||||
Message-ID: <aepiors@example.org>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
message in Drafts received later
|
||||
""".format(
|
||||
ac1.get_config("configured_addr"),
|
||||
),
|
||||
)
|
||||
ac1_direct_imap.append(
|
||||
"Spam",
|
||||
"""
|
||||
From: unknown.address@junk.org
|
||||
Subject: subj
|
||||
To: {}
|
||||
Message-ID: <spam.message@junk.org>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Unknown message in Spam
|
||||
""".format(
|
||||
ac1.get_config("configured_addr"),
|
||||
),
|
||||
)
|
||||
ac1_direct_imap.append(
|
||||
"Spam",
|
||||
"""
|
||||
From: unknown.address@junk.org, unkwnown.add@junk.org
|
||||
Subject: subj
|
||||
To: {}
|
||||
Message-ID: <spam.message2@junk.org>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Unknown & malformed message in Spam
|
||||
""".format(
|
||||
ac1.get_config("configured_addr"),
|
||||
),
|
||||
)
|
||||
ac1_direct_imap.append(
|
||||
"Spam",
|
||||
"""
|
||||
From: delta<address: inbox@nhroy.com>
|
||||
Subject: subj
|
||||
To: {}
|
||||
Message-ID: <spam.message99@junk.org>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Unknown & malformed message in Spam
|
||||
""".format(
|
||||
ac1.get_config("configured_addr"),
|
||||
),
|
||||
)
|
||||
ac1_direct_imap.append(
|
||||
"Spam",
|
||||
"""
|
||||
From: alice@example.org
|
||||
Subject: subj
|
||||
To: {}
|
||||
Message-ID: <spam.message3@junk.org>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Actually interesting message in Spam
|
||||
""".format(
|
||||
ac1.get_config("configured_addr"),
|
||||
),
|
||||
)
|
||||
ac1_direct_imap.append(
|
||||
"Junk",
|
||||
"""
|
||||
From: unknown.address@junk.org
|
||||
Subject: subj
|
||||
To: {}
|
||||
Message-ID: <spam.message@junk.org>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Unknown message in Junk
|
||||
""".format(
|
||||
ac1.get_config("configured_addr"),
|
||||
),
|
||||
)
|
||||
|
||||
ac1.set_config("scan_all_folders_debounce_secs", "0")
|
||||
log.section("All prepared, now let DC find the message")
|
||||
ac1.start_io()
|
||||
|
||||
# Wait until each folder was scanned, this is necessary for this test to test what it should test:
|
||||
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
|
||||
|
||||
fresh_msgs = list(ac1.get_fresh_messages())
|
||||
msg = fresh_msgs[0].get_snapshot()
|
||||
chat_msgs = msg.chat.get_messages()
|
||||
assert len(chat_msgs) == 1
|
||||
assert msg.text == "subj – Actually interesting message in Spam"
|
||||
|
||||
assert not any("unknown.address" in c.get_full_snapshot().name for c in ac1.get_chatlist())
|
||||
ac1_direct_imap.select_folder("Spam")
|
||||
assert ac1_direct_imap.get_uid_by_message_id("spam.message@junk.org")
|
||||
|
||||
ac1.stop_io()
|
||||
log.section("'Send out' the draft by moving it to Inbox, and wait for DC to display it this time")
|
||||
ac1_direct_imap.select_folder("Drafts")
|
||||
uid = ac1_direct_imap.get_uid_by_message_id("aepiors@example.org")
|
||||
ac1_direct_imap.conn.move(uid, "Inbox")
|
||||
|
||||
ac1.start_io()
|
||||
event = ac1.wait_for_event(EventType.MSGS_CHANGED)
|
||||
msg2 = Message(ac1, event.msg_id).get_snapshot()
|
||||
|
||||
assert msg2.text == "subj – message in Drafts received later"
|
||||
assert len(msg.chat.get_messages()) == 2
|
||||
|
||||
|
||||
def test_move_works_on_self_sent(acfactory):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
|
||||
# Enable movebox and wait until it is created.
|
||||
ac1.set_config("mvbox_move", "1")
|
||||
ac1.set_config("bcc_self", "1")
|
||||
ac1.bring_online()
|
||||
|
||||
chat = ac1.create_chat(ac2)
|
||||
chat.send_text("message1")
|
||||
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
|
||||
chat.send_text("message2")
|
||||
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
|
||||
chat.send_text("message3")
|
||||
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
|
||||
|
||||
|
||||
def test_moved_markseen(acfactory, direct_imap):
|
||||
def test_moved_markseen(acfactory, direct_imap, log):
|
||||
"""Test that message already moved to DeltaChat folder is marked as seen."""
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
ac2.set_config("mvbox_move", "1")
|
||||
ac1 = acfactory.get_online_account()
|
||||
|
||||
addr, password = acfactory.get_credentials()
|
||||
ac2 = acfactory.get_unconfigured_account()
|
||||
ac2.add_or_update_transport({"addr": addr, "password": password})
|
||||
ac2.bring_online()
|
||||
|
||||
log.section("ac2: creating DeltaChat folder")
|
||||
ac2_direct_imap = direct_imap(ac2)
|
||||
ac2_direct_imap.create_folder("DeltaChat")
|
||||
ac2.set_config("delete_server_after", "0")
|
||||
ac2.set_config("sync_msgs", "0") # Do not send a sync message when accepting a contact request.
|
||||
|
||||
ac2.add_or_update_transport({"addr": addr, "password": password, "imapFolder": "DeltaChat"})
|
||||
ac2.bring_online()
|
||||
|
||||
ac2.stop_io()
|
||||
@@ -326,6 +81,7 @@ def test_moved_markseen(acfactory, direct_imap):
|
||||
idle2.wait_for_new_message()
|
||||
|
||||
# Emulate moving of the message to DeltaChat folder by Sieve rule.
|
||||
log.section("ac2: moving message into DeltaChat folder")
|
||||
ac2_direct_imap.conn.move(["*"], "DeltaChat")
|
||||
ac2_direct_imap.select_folder("DeltaChat")
|
||||
assert len(list(ac2_direct_imap.conn.fetch("*", mark_seen=False))) == 1
|
||||
@@ -349,15 +105,11 @@ def test_moved_markseen(acfactory, direct_imap):
|
||||
assert len(list(ac2_direct_imap.conn.fetch(AND(seen=True, uid=U(1, "*")), mark_seen=False))) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mvbox_move", [True, False])
|
||||
def test_markseen_message_and_mdn(acfactory, direct_imap, mvbox_move):
|
||||
def test_markseen_message_and_mdn(acfactory, direct_imap):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
|
||||
for ac in ac1, ac2:
|
||||
ac.set_config("delete_server_after", "0")
|
||||
if mvbox_move:
|
||||
ac.set_config("mvbox_move", "1")
|
||||
ac.bring_online()
|
||||
|
||||
# Do not send BCC to self, we only want to test MDN on ac1.
|
||||
ac1.set_config("bcc_self", "0")
|
||||
@@ -366,10 +118,7 @@ def test_markseen_message_and_mdn(acfactory, direct_imap, mvbox_move):
|
||||
msg = ac2.wait_for_incoming_msg()
|
||||
msg.mark_seen()
|
||||
|
||||
if mvbox_move:
|
||||
rex = re.compile("Marked messages [0-9]+ in folder DeltaChat as seen.")
|
||||
else:
|
||||
rex = re.compile("Marked messages [0-9]+ in folder INBOX as seen.")
|
||||
rex = re.compile("Marked messages [0-9]+ in folder INBOX as seen.")
|
||||
|
||||
for ac in ac1, ac2:
|
||||
while True:
|
||||
@@ -377,12 +126,11 @@ def test_markseen_message_and_mdn(acfactory, direct_imap, mvbox_move):
|
||||
if event.kind == EventType.INFO and rex.search(event.msg):
|
||||
break
|
||||
|
||||
folder = "mvbox" if mvbox_move else "inbox"
|
||||
ac1_direct_imap = direct_imap(ac1)
|
||||
ac2_direct_imap = direct_imap(ac2)
|
||||
|
||||
ac1_direct_imap.select_config_folder(folder)
|
||||
ac2_direct_imap.select_config_folder(folder)
|
||||
ac1_direct_imap.select_folder("INBOX")
|
||||
ac2_direct_imap.select_folder("INBOX")
|
||||
|
||||
# Check that the mdn is marked as seen
|
||||
assert len(list(ac1_direct_imap.conn.fetch(AND(seen=True), mark_seen=False))) == 1
|
||||
@@ -390,120 +138,12 @@ def test_markseen_message_and_mdn(acfactory, direct_imap, mvbox_move):
|
||||
assert len(list(ac2_direct_imap.conn.fetch(AND(seen=True), mark_seen=False))) == 1
|
||||
|
||||
|
||||
def test_mvbox_and_trash(acfactory, direct_imap, log):
|
||||
log.section("ac1: start with mvbox")
|
||||
ac1 = acfactory.get_online_account()
|
||||
ac1.set_config("mvbox_move", "1")
|
||||
ac1.bring_online()
|
||||
|
||||
log.section("ac2: start without a mvbox")
|
||||
ac2 = acfactory.get_online_account()
|
||||
|
||||
log.section("ac1: create trash")
|
||||
ac1_direct_imap = direct_imap(ac1)
|
||||
ac1_direct_imap.create_folder("Trash")
|
||||
ac1.set_config("scan_all_folders_debounce_secs", "0")
|
||||
ac1.stop_io()
|
||||
ac1.start_io()
|
||||
|
||||
log.section("ac1: send message and wait for ac2 to receive it")
|
||||
acfactory.get_accepted_chat(ac1, ac2).send_text("message1")
|
||||
assert ac2.wait_for_incoming_msg().get_snapshot().text == "message1"
|
||||
|
||||
assert ac1.get_config("configured_mvbox_folder") == "DeltaChat"
|
||||
while ac1.get_config("configured_trash_folder") != "Trash":
|
||||
ac1.wait_for_event(EventType.CONNECTIVITY_CHANGED)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("folder", "move", "expected_destination"),
|
||||
[
|
||||
(
|
||||
"xyz",
|
||||
False,
|
||||
"xyz",
|
||||
), # Test that emails aren't found in a random folder
|
||||
(
|
||||
"xyz",
|
||||
True,
|
||||
"xyz",
|
||||
), # ...emails are found in a random folder and downloaded without moving
|
||||
(
|
||||
"Spam",
|
||||
False,
|
||||
"INBOX",
|
||||
), # ...emails are moved from the spam folder to the Inbox
|
||||
],
|
||||
)
|
||||
# Testrun.org does not support the CREATE-SPECIAL-USE capability, which means that we can't create a folder with
|
||||
# the "\Junk" flag (see https://tools.ietf.org/html/rfc6154). So, we can't test spam folder detection by flag.
|
||||
def test_scan_folders(acfactory, log, direct_imap, folder, move, expected_destination):
|
||||
"""Delta Chat periodically scans all folders for new messages to make sure we don't miss any."""
|
||||
variant = folder + "-" + str(move) + "-" + expected_destination
|
||||
log.section("Testing variant " + variant)
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
ac1.set_config("delete_server_after", "0")
|
||||
if move:
|
||||
ac1.set_config("mvbox_move", "1")
|
||||
ac1.bring_online()
|
||||
|
||||
ac1.stop_io()
|
||||
ac1_direct_imap = direct_imap(ac1)
|
||||
ac1_direct_imap.create_folder(folder)
|
||||
|
||||
# Wait until each folder was selected once and we are IDLEing:
|
||||
ac1.start_io()
|
||||
ac1.bring_online()
|
||||
|
||||
ac1.stop_io()
|
||||
assert folder in ac1_direct_imap.list_folders()
|
||||
|
||||
log.section("Send a message from ac2 to ac1 and manually move it to `folder`")
|
||||
ac1_direct_imap.select_config_folder("inbox")
|
||||
with ac1_direct_imap.idle() as idle1:
|
||||
acfactory.get_accepted_chat(ac2, ac1).send_text("hello")
|
||||
idle1.wait_for_new_message()
|
||||
ac1_direct_imap.conn.move(["*"], folder) # "*" means "biggest UID in mailbox"
|
||||
|
||||
log.section("start_io() and see if DeltaChat finds the message (" + variant + ")")
|
||||
ac1.set_config("scan_all_folders_debounce_secs", "0")
|
||||
ac1.start_io()
|
||||
chat = ac1.create_chat(ac2)
|
||||
n_msgs = 1 # "Messages are end-to-end encrypted."
|
||||
if folder == "Spam":
|
||||
msg = ac1.wait_for_incoming_msg().get_snapshot()
|
||||
assert msg.text == "hello"
|
||||
n_msgs += 1
|
||||
else:
|
||||
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
|
||||
assert len(chat.get_messages()) == n_msgs
|
||||
|
||||
# The message has reached its destination.
|
||||
ac1_direct_imap.select_folder(expected_destination)
|
||||
assert len(ac1_direct_imap.get_all_messages()) == 1
|
||||
if folder != expected_destination:
|
||||
ac1_direct_imap.select_folder(folder)
|
||||
assert len(ac1_direct_imap.get_all_messages()) == 0
|
||||
|
||||
|
||||
def test_trash_multiple_messages(acfactory, direct_imap, log):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
ac2.stop_io()
|
||||
|
||||
# Create the Trash folder on IMAP server and configure deletion to it. There was a bug that if
|
||||
# Trash wasn't configured initially, it can't be configured later, let's check this.
|
||||
log.section("Creating trash folder")
|
||||
ac2_direct_imap = direct_imap(ac2)
|
||||
ac2_direct_imap.create_folder("Trash")
|
||||
ac2.set_config("delete_server_after", "0")
|
||||
ac2.set_config("sync_msgs", "0")
|
||||
ac2.set_config("delete_to_trash", "1")
|
||||
|
||||
log.section("Check that Trash can be configured initially as well")
|
||||
ac3 = ac2.clone()
|
||||
ac3.bring_online()
|
||||
assert ac3.get_config("configured_trash_folder")
|
||||
ac3.stop_io()
|
||||
|
||||
ac2.start_io()
|
||||
chat12 = acfactory.get_accepted_chat(ac1, ac2)
|
||||
@@ -520,17 +160,15 @@ def test_trash_multiple_messages(acfactory, direct_imap, log):
|
||||
assert msg.text in texts
|
||||
if text != "second":
|
||||
to_delete.append(msg)
|
||||
# ac2 has received some messages, this is impossible w/o the trash folder configured, let's
|
||||
# check the configuration.
|
||||
assert ac2.get_config("configured_trash_folder") == "Trash"
|
||||
|
||||
log.section("ac2: deleting all messages except second")
|
||||
assert len(to_delete) == len(texts) - 1
|
||||
ac2.delete_messages(to_delete)
|
||||
|
||||
log.section("ac2: test that only one message is left")
|
||||
ac2_direct_imap = direct_imap(ac2)
|
||||
while 1:
|
||||
ac2.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
|
||||
ac2.wait_for_event(EventType.IMAP_MESSAGE_DELETED)
|
||||
ac2_direct_imap.select_config_folder("inbox")
|
||||
nr_msgs = len(ac2_direct_imap.get_all_messages())
|
||||
assert nr_msgs > 0
|
||||
|
||||
@@ -24,6 +24,13 @@ def path_to_webxdc(request):
|
||||
return str(p)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def path_to_large_webxdc(request):
|
||||
p = request.path.parent.parent.parent.joinpath("test-data/webxdc/realtime-check.xdc")
|
||||
assert p.exists()
|
||||
return str(p)
|
||||
|
||||
|
||||
def log(msg):
|
||||
logging.info(msg)
|
||||
|
||||
@@ -227,3 +234,29 @@ def test_advertisement_after_chatting(acfactory, path_to_webxdc):
|
||||
ac2_webxdc_msg.send_webxdc_realtime_advertisement()
|
||||
event = ac1.wait_for_event(EventType.WEBXDC_REALTIME_ADVERTISEMENT_RECEIVED)
|
||||
assert event.msg_id == ac1_webxdc_msg.id
|
||||
|
||||
|
||||
def test_realtime_large_webxdc(acfactory, path_to_large_webxdc):
|
||||
"""Tests initializing realtime channel on a large webxdc.
|
||||
|
||||
This is a regression test for a bug that existed in version 2.42.0.
|
||||
Large webxdc is split into pre- and post- message,
|
||||
and this previously resulted in failure to initialize realtime.
|
||||
"""
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
ac1.set_config("webxdc_realtime_enabled", "1")
|
||||
ac2.set_config("webxdc_realtime_enabled", "1")
|
||||
|
||||
ac2.create_chat(ac1)
|
||||
ac1_ac2_chat = ac1.create_chat(ac2)
|
||||
ac1_webxdc_msg = ac1_ac2_chat.send_message(text="realtime check", file=path_to_large_webxdc)
|
||||
|
||||
# Receive pre-message.
|
||||
ac2_webxdc_msg = ac2.wait_for_incoming_msg()
|
||||
|
||||
# Receive post-message.
|
||||
ac2_webxdc_msg = ac2.wait_for_msg(EventType.MSGS_CHANGED)
|
||||
|
||||
ac2_webxdc_msg.send_webxdc_realtime_advertisement()
|
||||
event = ac1.wait_for_event(EventType.WEBXDC_REALTIME_ADVERTISEMENT_RECEIVED)
|
||||
assert event.msg_id == ac1_webxdc_msg.id
|
||||
|
||||
329
deltachat-rpc-client/tests/test_multitransport.py
Normal file
329
deltachat-rpc-client/tests/test_multitransport.py
Normal file
@@ -0,0 +1,329 @@
|
||||
import pytest
|
||||
|
||||
from deltachat_rpc_client import EventType
|
||||
from deltachat_rpc_client.const import DownloadState
|
||||
from deltachat_rpc_client.rpc import JsonRpcError
|
||||
|
||||
|
||||
def test_add_second_address(acfactory) -> None:
|
||||
account = acfactory.new_configured_account()
|
||||
assert len(account.list_transports()) == 1
|
||||
|
||||
assert account.get_config("show_emails") == "2"
|
||||
|
||||
qr = acfactory.get_account_qr()
|
||||
account.add_transport_from_qr(qr)
|
||||
assert len(account.list_transports()) == 2
|
||||
|
||||
account.add_transport_from_qr(qr)
|
||||
assert len(account.list_transports()) == 3
|
||||
|
||||
first_addr = account.list_transports()[0]["addr"]
|
||||
second_addr = account.list_transports()[1]["addr"]
|
||||
|
||||
# Cannot delete the first address.
|
||||
with pytest.raises(JsonRpcError):
|
||||
account.delete_transport(first_addr)
|
||||
|
||||
account.delete_transport(second_addr)
|
||||
assert len(account.list_transports()) == 2
|
||||
|
||||
# show_emails does not matter for multi-relay, can be set to anything
|
||||
account.set_config("show_emails", "0")
|
||||
|
||||
|
||||
def test_second_transport_without_classic_emails(acfactory) -> None:
|
||||
"""Test that second transport can be configured if classic emails are not fetched."""
|
||||
account = acfactory.new_configured_account()
|
||||
assert len(account.list_transports()) == 1
|
||||
|
||||
assert account.get_config("show_emails") == "2"
|
||||
|
||||
qr = acfactory.get_account_qr()
|
||||
account.set_config("show_emails", "0")
|
||||
|
||||
account.add_transport_from_qr(qr)
|
||||
|
||||
|
||||
def test_change_address(acfactory) -> None:
|
||||
"""Test Alice configuring a second transport and setting it as a primary one."""
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
bob_addr = bob.get_config("configured_addr")
|
||||
bob.create_chat(alice)
|
||||
|
||||
alice_chat_bob = alice.create_chat(bob)
|
||||
alice_chat_bob.send_text("Hello!")
|
||||
|
||||
msg1 = bob.wait_for_incoming_msg().get_snapshot()
|
||||
sender_addr1 = msg1.sender.get_snapshot().address
|
||||
|
||||
alice.stop_io()
|
||||
old_alice_addr = alice.get_config("configured_addr")
|
||||
alice_vcard = alice.self_contact.make_vcard()
|
||||
assert old_alice_addr in alice_vcard
|
||||
qr = acfactory.get_account_qr()
|
||||
alice.add_transport_from_qr(qr)
|
||||
new_alice_addr = alice.list_transports()[1]["addr"]
|
||||
with pytest.raises(JsonRpcError):
|
||||
# Cannot use the address that is not
|
||||
# configured for any transport.
|
||||
alice.set_config("configured_addr", bob_addr)
|
||||
|
||||
# Load old address so it is cached.
|
||||
assert alice.get_config("configured_addr") == old_alice_addr
|
||||
alice.set_config("configured_addr", new_alice_addr)
|
||||
# Make sure that setting `configured_addr` invalidated the cache.
|
||||
assert alice.get_config("configured_addr") == new_alice_addr
|
||||
|
||||
alice_vcard = alice.self_contact.make_vcard()
|
||||
assert old_alice_addr not in alice_vcard
|
||||
assert new_alice_addr in alice_vcard
|
||||
with pytest.raises(JsonRpcError):
|
||||
alice.delete_transport(new_alice_addr)
|
||||
alice.start_io()
|
||||
|
||||
alice_chat_bob.send_text("Hello again!")
|
||||
|
||||
msg2 = bob.wait_for_incoming_msg().get_snapshot()
|
||||
sender_addr2 = msg2.sender.get_snapshot().address
|
||||
|
||||
assert msg1.sender == msg2.sender
|
||||
assert sender_addr1 != sender_addr2
|
||||
assert sender_addr1 == old_alice_addr
|
||||
assert sender_addr2 == new_alice_addr
|
||||
|
||||
|
||||
def test_download_on_demand(acfactory) -> None:
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
alice.set_config("download_limit", "1")
|
||||
|
||||
alice.stop_io()
|
||||
qr = acfactory.get_account_qr()
|
||||
alice.add_transport_from_qr(qr)
|
||||
alice.start_io()
|
||||
|
||||
alice.create_chat(bob)
|
||||
chat_bob_alice = bob.create_chat(alice)
|
||||
chat_bob_alice.send_message(file="../test-data/image/screenshot.jpg")
|
||||
msg = alice.wait_for_incoming_msg()
|
||||
snapshot = msg.get_snapshot()
|
||||
assert snapshot.download_state == DownloadState.AVAILABLE
|
||||
chat_id = snapshot.chat_id
|
||||
# Actually the message isn't available yet. Wait somehow for the post-message to arrive.
|
||||
chat_bob_alice.send_message("Now you can download my previous message")
|
||||
alice.wait_for_incoming_msg()
|
||||
alice._rpc.download_full_message(alice.id, msg.id)
|
||||
for dstate in [DownloadState.IN_PROGRESS, DownloadState.DONE]:
|
||||
event = alice.wait_for_event(EventType.MSGS_CHANGED)
|
||||
assert event.chat_id == chat_id
|
||||
assert event.msg_id == msg.id
|
||||
assert msg.get_snapshot().download_state == dstate
|
||||
|
||||
|
||||
def test_reconfigure_transport(acfactory) -> None:
|
||||
"""Test that reconfiguring the transport works."""
|
||||
account = acfactory.get_online_account()
|
||||
|
||||
[transport] = account.list_transports()
|
||||
account.add_or_update_transport(transport)
|
||||
|
||||
|
||||
def test_transport_synchronization(acfactory, log) -> None:
|
||||
"""Test synchronization of transports between devices."""
|
||||
|
||||
def wait_for_io_started(ac):
|
||||
while True:
|
||||
ev = ac.wait_for_event(EventType.INFO)
|
||||
if "scheduler is running" in ev.msg:
|
||||
return
|
||||
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
ac1_clone = ac1.clone()
|
||||
ac1_clone.bring_online()
|
||||
|
||||
qr = acfactory.get_account_qr()
|
||||
|
||||
ac1.add_transport_from_qr(qr)
|
||||
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
|
||||
wait_for_io_started(ac1_clone)
|
||||
assert len(ac1.list_transports()) == 2
|
||||
assert len(ac1_clone.list_transports()) == 2
|
||||
|
||||
ac1_clone.add_transport_from_qr(qr)
|
||||
ac1.wait_for_event(EventType.TRANSPORTS_MODIFIED)
|
||||
wait_for_io_started(ac1)
|
||||
assert len(ac1.list_transports()) == 3
|
||||
assert len(ac1_clone.list_transports()) == 3
|
||||
|
||||
log.section("ac1 clone removes second transport")
|
||||
[transport1, transport2, transport3] = ac1_clone.list_transports()
|
||||
addr3 = transport3["addr"]
|
||||
ac1_clone.delete_transport(transport2["addr"])
|
||||
|
||||
ac1.wait_for_event(EventType.TRANSPORTS_MODIFIED)
|
||||
wait_for_io_started(ac1)
|
||||
[transport1, transport3] = ac1.list_transports()
|
||||
|
||||
log.section("ac1 changes the primary transport")
|
||||
ac1.set_config("configured_addr", transport3["addr"])
|
||||
|
||||
# One event for updated `add_timestamp` of the new primary transport,
|
||||
# one event for the `configured_addr` update.
|
||||
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
|
||||
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
|
||||
[transport1, transport3] = ac1_clone.list_transports()
|
||||
assert ac1_clone.get_config("configured_addr") == addr3
|
||||
|
||||
log.section("ac1 removes the first transport")
|
||||
ac1.delete_transport(transport1["addr"])
|
||||
|
||||
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
|
||||
wait_for_io_started(ac1_clone)
|
||||
[transport3] = ac1_clone.list_transports()
|
||||
assert transport3["addr"] == addr3
|
||||
assert ac1_clone.get_config("configured_addr") == addr3
|
||||
|
||||
ac2_chat = ac2.create_chat(ac1)
|
||||
ac2_chat.send_text("Hello!")
|
||||
|
||||
assert ac1.wait_for_incoming_msg().get_snapshot().text == "Hello!"
|
||||
assert ac1_clone.wait_for_incoming_msg().get_snapshot().text == "Hello!"
|
||||
|
||||
|
||||
def test_transport_sync_new_as_primary(acfactory, log) -> None:
|
||||
"""Test synchronization of new transport as primary between devices."""
|
||||
ac1, bob = acfactory.get_online_accounts(2)
|
||||
ac1_clone = ac1.clone()
|
||||
ac1_clone.bring_online()
|
||||
|
||||
qr = acfactory.get_account_qr()
|
||||
|
||||
ac1.add_transport_from_qr(qr)
|
||||
ac1_transports = ac1.list_transports()
|
||||
assert len(ac1_transports) == 2
|
||||
[transport1, transport2] = ac1_transports
|
||||
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
|
||||
assert len(ac1_clone.list_transports()) == 2
|
||||
assert ac1_clone.get_config("configured_addr") == transport1["addr"]
|
||||
|
||||
log.section("ac1 changes the primary transport")
|
||||
ac1.set_config("configured_addr", transport2["addr"])
|
||||
|
||||
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
|
||||
assert ac1_clone.get_config("configured_addr") == transport2["addr"]
|
||||
|
||||
log.section("ac1_clone receives a message via the new primary transport")
|
||||
ac1_chat = ac1.create_chat(bob)
|
||||
ac1_chat.send_text("Hello!")
|
||||
bob_chat_id = bob.wait_for_incoming_msg_event().chat_id
|
||||
bob_chat = bob.get_chat_by_id(bob_chat_id)
|
||||
bob_chat.accept()
|
||||
bob_chat.send_text("hello back")
|
||||
assert ac1_clone.wait_for_incoming_msg().get_snapshot().text == "hello back"
|
||||
|
||||
|
||||
def test_recognize_self_address(acfactory) -> None:
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
bob_chat = bob.create_chat(alice)
|
||||
|
||||
qr = acfactory.get_account_qr()
|
||||
alice.add_transport_from_qr(qr)
|
||||
|
||||
new_alice_addr = alice.list_transports()[1]["addr"]
|
||||
alice.set_config("configured_addr", new_alice_addr)
|
||||
|
||||
bob_chat.send_text("Hello!")
|
||||
msg = alice.wait_for_incoming_msg().get_snapshot()
|
||||
assert msg.chat == alice.create_chat(bob)
|
||||
|
||||
|
||||
def test_transport_limit(acfactory) -> None:
|
||||
"""Test transports limit."""
|
||||
account = acfactory.get_online_account()
|
||||
qr = acfactory.get_account_qr()
|
||||
|
||||
limit = 5
|
||||
|
||||
for _ in range(1, limit):
|
||||
account.add_transport_from_qr(qr)
|
||||
|
||||
assert len(account.list_transports()) == limit
|
||||
|
||||
with pytest.raises(JsonRpcError):
|
||||
account.add_transport_from_qr(qr)
|
||||
|
||||
second_addr = account.list_transports()[1]["addr"]
|
||||
account.delete_transport(second_addr)
|
||||
|
||||
# test that adding a transport after deleting one works again
|
||||
account.add_transport_from_qr(qr)
|
||||
|
||||
|
||||
def test_message_info_imap_urls(acfactory) -> None:
|
||||
"""Test that message info contains IMAP URLs of where the message was received."""
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
qr = acfactory.get_account_qr()
|
||||
for i in range(3):
|
||||
alice.add_transport_from_qr(qr)
|
||||
# Wait for all transports to go IDLE after adding each one.
|
||||
for _ in range(i + 1):
|
||||
alice.bring_online()
|
||||
|
||||
# Enable multi-device mode so messages are not deleted immediately.
|
||||
alice.set_config("bcc_self", "1")
|
||||
|
||||
# Bob creates chat, learning about Alice's currently selected transport.
|
||||
# This is where he will send the message.
|
||||
bob_chat = bob.create_chat(alice)
|
||||
|
||||
# Alice switches to another transport and removes the rest of the transports.
|
||||
new_alice_addr = alice.list_transports()[1]["addr"]
|
||||
alice.set_config("configured_addr", new_alice_addr)
|
||||
removed_addrs = []
|
||||
for transport in alice.list_transports():
|
||||
if transport["addr"] != new_alice_addr:
|
||||
alice.delete_transport(transport["addr"])
|
||||
removed_addrs.append(transport["addr"])
|
||||
alice.stop_io()
|
||||
alice.start_io()
|
||||
|
||||
bob_chat.send_text("Hello!")
|
||||
|
||||
msg = alice.wait_for_incoming_msg()
|
||||
msg_info = msg.get_info()
|
||||
assert new_alice_addr in msg_info
|
||||
for removed_addr in removed_addrs:
|
||||
assert removed_addr not in msg_info
|
||||
assert f"{new_alice_addr}/INBOX" in msg_info
|
||||
|
||||
|
||||
def test_remove_primary_transport(acfactory) -> None:
|
||||
"""Test that after removing the primary relay, Alice can still receive messages."""
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
qr = acfactory.get_account_qr()
|
||||
|
||||
alice.add_transport_from_qr(qr)
|
||||
alice.bring_online()
|
||||
|
||||
bob_chat = bob.create_chat(alice)
|
||||
alice.create_chat(bob)
|
||||
|
||||
# Alice changes the transport.
|
||||
[transport1, transport2] = alice.list_transports()
|
||||
alice.set_config("configured_addr", transport2["addr"])
|
||||
|
||||
bob_chat.send_text("Hello!")
|
||||
msg1 = alice.wait_for_incoming_msg().get_snapshot()
|
||||
assert msg1.text == "Hello!"
|
||||
|
||||
# Alice deletes the first transport.
|
||||
alice.delete_transport(transport1["addr"])
|
||||
alice.stop_io()
|
||||
alice.start_io()
|
||||
|
||||
bob_chat.send_text("Hello again!")
|
||||
msg2 = alice.wait_for_incoming_msg().get_snapshot()
|
||||
assert msg2.text == "Hello again!"
|
||||
@@ -158,29 +158,34 @@ def test_qr_securejoin_broadcast(acfactory, all_devices_online):
|
||||
chat = get_broadcast(ac)
|
||||
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()
|
||||
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[1].get_snapshot()
|
||||
if please_wait_info_msg:
|
||||
first_msg = chat_msgs.pop(0).get_snapshot()
|
||||
assert "invited you to join this channel" in first_msg.text
|
||||
assert first_msg.is_info
|
||||
|
||||
if inviter_side:
|
||||
member_added_msg = chat_msgs.pop(0).get_snapshot()
|
||||
assert member_added_msg.text == f"Member {contact_snapshot.display_name} added."
|
||||
assert member_added_msg.info_contact_id == contact_snapshot.id
|
||||
else:
|
||||
assert member_added_msg.text == "You joined the channel."
|
||||
if chat_msgs[0].get_snapshot().text == "You joined the channel.":
|
||||
member_added_msg = chat_msgs.pop(0).get_snapshot()
|
||||
else:
|
||||
member_added_msg = chat_msgs.pop(1).get_snapshot()
|
||||
assert member_added_msg.text == "You joined the channel."
|
||||
assert member_added_msg.is_info
|
||||
|
||||
hello_msg = chat_msgs[2].get_snapshot()
|
||||
hello_msg = chat_msgs.pop(0).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
|
||||
assert len(chat_msgs) == 0
|
||||
|
||||
chat_snapshot = chat.get_full_snapshot()
|
||||
assert chat_snapshot.is_encrypted
|
||||
@@ -696,6 +701,6 @@ def test_withdraw_securejoin_qr(acfactory):
|
||||
event = alice.wait_for_event()
|
||||
if (
|
||||
event.kind == EventType.WARNING
|
||||
and "Ignoring vg-request-with-auth message because of invalid auth code." in event.msg
|
||||
and "Ignoring RequestWithAuth message because of invalid auth code." in event.msg
|
||||
):
|
||||
break
|
||||
|
||||
@@ -10,10 +10,10 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from deltachat_rpc_client import Contact, EventType, Message, events
|
||||
from deltachat_rpc_client import EventType, events
|
||||
from deltachat_rpc_client.const import DownloadState, MessageState
|
||||
from deltachat_rpc_client.pytestplugin import E2EE_INFO_MSGS
|
||||
from deltachat_rpc_client.rpc import JsonRpcError
|
||||
from deltachat_rpc_client.rpc import JsonRpcError, Rpc
|
||||
|
||||
|
||||
def test_system_info(rpc) -> None:
|
||||
@@ -90,12 +90,9 @@ def test_lowercase_address(acfactory) -> None:
|
||||
assert account.get_config("configured_addr") == addr
|
||||
assert account.list_transports()[0]["addr"] == addr
|
||||
|
||||
for param in [
|
||||
account.get_info()["used_account_settings"],
|
||||
account.get_info()["entered_account_settings"],
|
||||
]:
|
||||
assert addr in param
|
||||
assert addr_upper not in param
|
||||
param = account.get_info()["used_transport_settings"]
|
||||
assert addr in param
|
||||
assert addr_upper not in param
|
||||
|
||||
|
||||
def test_configure_ip(acfactory) -> None:
|
||||
@@ -336,26 +333,27 @@ def test_receive_imf_failure(acfactory) -> None:
|
||||
alice_contact_bob = alice.create_contact(bob, "Bob")
|
||||
alice_chat_bob = alice_contact_bob.create_chat()
|
||||
|
||||
bob.set_config("fail_on_receiving_full_msg", "1")
|
||||
bob.set_config("simulate_receive_imf_error", "1")
|
||||
alice_chat_bob.send_text("Hello!")
|
||||
event = bob.wait_for_event(EventType.MSGS_CHANGED)
|
||||
assert event.chat_id == bob.get_device_chat().id
|
||||
msg_id = event.msg_id
|
||||
message = bob.get_message_by_id(msg_id)
|
||||
snapshot = message.get_snapshot()
|
||||
version = bob.get_info()["deltachat_core_version"]
|
||||
assert (
|
||||
snapshot.text == "❌ Failed to receive a message:"
|
||||
" Condition failed: `!context.get_config_bool(Config::FailOnReceivingFullMsg).await?`."
|
||||
" Condition failed: `!context.get_config_bool(Config::SimulateReceiveImfError).await?`."
|
||||
f" Core version {version}."
|
||||
" Please report this bug to delta@merlinux.eu or https://support.delta.chat/."
|
||||
)
|
||||
|
||||
# The failed message doesn't break the IMAP loop.
|
||||
bob.set_config("fail_on_receiving_full_msg", "0")
|
||||
bob.set_config("simulate_receive_imf_error", "0")
|
||||
alice_chat_bob.send_text("Hello again!")
|
||||
message = bob.wait_for_incoming_msg()
|
||||
snapshot = message.get_snapshot()
|
||||
assert snapshot.text == "Hello again!"
|
||||
assert snapshot.download_state == DownloadState.DONE
|
||||
assert snapshot.error is None
|
||||
|
||||
|
||||
@@ -373,17 +371,48 @@ def test_selfavatar_sync(acfactory, data, log) -> None:
|
||||
alice.set_config("selfavatar", image)
|
||||
avatar_config = alice.get_config("selfavatar")
|
||||
avatar_hash = os.path.basename(avatar_config)
|
||||
print("Info: avatar hash is ", avatar_hash)
|
||||
logging.info(f"Avatar hash is {avatar_hash}")
|
||||
|
||||
log.section("First device receives avatar change")
|
||||
alice2.wait_for_event(EventType.SELFAVATAR_CHANGED)
|
||||
avatar_config2 = alice2.get_config("selfavatar")
|
||||
avatar_hash2 = os.path.basename(avatar_config2)
|
||||
print("Info: avatar hash on second device is ", avatar_hash2)
|
||||
logging.info(f"Avatar hash on second device is {avatar_hash2}")
|
||||
assert avatar_hash == avatar_hash2
|
||||
assert avatar_config != avatar_config2
|
||||
|
||||
|
||||
def test_dont_move_sync_msgs(acfactory, direct_imap):
|
||||
addr, password = acfactory.get_credentials()
|
||||
ac1 = acfactory.get_unconfigured_account()
|
||||
ac1.set_config("bcc_self", "1")
|
||||
ac1.set_config("fix_is_chatmail", "1")
|
||||
ac1.add_or_update_transport({"addr": addr, "password": password})
|
||||
ac1.start_io()
|
||||
ac1_direct_imap = direct_imap(ac1)
|
||||
|
||||
# Sync messages may also be sent during configuration.
|
||||
ac1.wait_for_event(EventType.MSG_DELIVERED)
|
||||
ac1_direct_imap.select_folder("Inbox")
|
||||
while True:
|
||||
if len(ac1_direct_imap.get_all_messages()) == 1:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
ac1.set_config("displayname", "Alice")
|
||||
ac1.wait_for_event(EventType.MSG_DELIVERED)
|
||||
ac1.set_config("displayname", "Bob")
|
||||
ac1.wait_for_event(EventType.MSG_DELIVERED)
|
||||
|
||||
# Message may not be delivered to IMAP immediately
|
||||
# after sending over SMTP,
|
||||
# retry until they are delivered to IMAP.
|
||||
while True:
|
||||
if len(ac1_direct_imap.get_all_messages()) == 3:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def test_reaction_seen_on_another_dev(acfactory) -> None:
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
alice2 = alice.clone()
|
||||
@@ -467,7 +496,7 @@ def test_bot(acfactory) -> None:
|
||||
|
||||
|
||||
def test_wait_next_messages(acfactory) -> None:
|
||||
alice = acfactory.new_configured_account()
|
||||
alice = acfactory.get_online_account()
|
||||
|
||||
# Create a bot account so it does not receive device messages in the beginning.
|
||||
addr, password = acfactory.get_credentials()
|
||||
@@ -475,6 +504,7 @@ def test_wait_next_messages(acfactory) -> None:
|
||||
bot.set_config("bot", "1")
|
||||
bot.add_or_update_transport({"addr": addr, "password": password})
|
||||
assert bot.is_configured()
|
||||
bot.bring_online()
|
||||
|
||||
# There are no old messages and the call returns immediately.
|
||||
assert not bot.wait_next_messages()
|
||||
@@ -507,6 +537,103 @@ def test_import_export_backup(acfactory, tmp_path) -> None:
|
||||
assert alice2.manager.get_system_info()
|
||||
|
||||
|
||||
def test_import_export_online_all(acfactory, tmp_path, data, log) -> None:
|
||||
(ac1, some1) = acfactory.get_online_accounts(2)
|
||||
|
||||
log.section("create some chat content")
|
||||
some1_addr = some1.get_config("addr")
|
||||
chat1 = ac1.create_contact(some1).create_chat()
|
||||
chat1.send_text("msg1")
|
||||
assert len(ac1.get_contacts()) == 1
|
||||
|
||||
original_image_path = data.get_path("image/avatar64x64.png")
|
||||
chat1.send_file(str(original_image_path))
|
||||
|
||||
# Add another 100KB file that ensures that the progress is smooth enough
|
||||
path = tmp_path / "attachment.txt"
|
||||
with path.open("w") as file:
|
||||
file.truncate(100000)
|
||||
chat1.send_file(str(path))
|
||||
|
||||
def assert_account_is_proper(ac):
|
||||
contacts = ac.get_contacts()
|
||||
assert len(contacts) == 1
|
||||
contact2 = contacts[0]
|
||||
assert contact2.get_snapshot().address == some1_addr
|
||||
chat2 = contact2.create_chat()
|
||||
messages = chat2.get_messages()
|
||||
assert len(messages) == 3 + E2EE_INFO_MSGS
|
||||
assert messages[0 + E2EE_INFO_MSGS].get_snapshot().text == "msg1"
|
||||
snapshot = messages[1 + E2EE_INFO_MSGS].get_snapshot()
|
||||
assert snapshot.file_mime == "image/png"
|
||||
assert os.stat(snapshot.file).st_size == os.stat(original_image_path).st_size
|
||||
ac.set_config("displayname", "new displayname")
|
||||
assert ac.get_config("displayname") == "new displayname"
|
||||
|
||||
assert_account_is_proper(ac1)
|
||||
|
||||
backupdir = tmp_path / "backup"
|
||||
backupdir.mkdir()
|
||||
|
||||
log.section(f"export all to {backupdir}")
|
||||
ac1.stop_io()
|
||||
ac1.export_backup(backupdir)
|
||||
progress = 0
|
||||
files_written = []
|
||||
while True:
|
||||
event = ac1.wait_for_event()
|
||||
if event.kind == EventType.IMEX_PROGRESS:
|
||||
assert event.progress > 0 # Progress 0 indicates error.
|
||||
assert event.progress < progress + 250
|
||||
progress = event.progress
|
||||
if progress == 1000:
|
||||
break
|
||||
elif event.kind == EventType.IMEX_FILE_WRITTEN:
|
||||
files_written.append(event.path)
|
||||
else:
|
||||
logging.info(event)
|
||||
assert len(files_written) == 1
|
||||
assert os.path.exists(files_written[0])
|
||||
ac1.start_io()
|
||||
|
||||
log.section("get fresh empty account")
|
||||
ac2 = acfactory.get_unconfigured_account()
|
||||
|
||||
log.section("import backup and check it's proper")
|
||||
ac2.import_backup(files_written[0])
|
||||
progress = 0
|
||||
while True:
|
||||
event = ac2.wait_for_event()
|
||||
if event.kind == EventType.IMEX_PROGRESS:
|
||||
assert event.progress > 0 # Progress 0 indicates error.
|
||||
assert event.progress < progress + 250
|
||||
progress = event.progress
|
||||
if progress == 1000:
|
||||
break
|
||||
else:
|
||||
logging.info(event)
|
||||
assert_account_is_proper(ac1)
|
||||
assert_account_is_proper(ac2)
|
||||
|
||||
log.section(f"Second-time export all to {backupdir}")
|
||||
ac1.stop_io()
|
||||
ac1.export_backup(backupdir)
|
||||
while True:
|
||||
event = ac1.wait_for_event()
|
||||
if event.kind == EventType.IMEX_PROGRESS:
|
||||
assert event.progress > 0
|
||||
if event.progress == 1000:
|
||||
break
|
||||
elif event.kind == EventType.IMEX_FILE_WRITTEN:
|
||||
files_written.append(event.path)
|
||||
else:
|
||||
logging.info(event)
|
||||
assert len(files_written) == 2
|
||||
assert os.path.exists(files_written[1])
|
||||
assert files_written[1] != files_written[0]
|
||||
assert len(list(backupdir.glob("*.tar"))) == 2
|
||||
|
||||
|
||||
def test_import_export_keys(acfactory, tmp_path) -> None:
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
@@ -538,6 +665,24 @@ def test_openrpc_command_line() -> None:
|
||||
assert "methods" in openrpc
|
||||
|
||||
|
||||
def test_early_failure(tmp_path) -> None:
|
||||
"""Test that Rpc.start() raises on invalid accounts directories."""
|
||||
# A file instead of a directory.
|
||||
file_path = tmp_path / "not_a_dir"
|
||||
file_path.write_text("I am a file, not a directory")
|
||||
rpc = Rpc(accounts_dir=str(file_path))
|
||||
with pytest.raises(JsonRpcError, match="(?i)directory"):
|
||||
rpc.start()
|
||||
|
||||
# A non-empty directory that is not a deltachat accounts directory.
|
||||
non_dc_dir = tmp_path / "invalid_dir"
|
||||
non_dc_dir.mkdir()
|
||||
(non_dc_dir / "some_file").write_text("content")
|
||||
rpc = Rpc(accounts_dir=str(non_dc_dir))
|
||||
with pytest.raises(JsonRpcError, match="invalid_dir"):
|
||||
rpc.start()
|
||||
|
||||
|
||||
def test_provider_info(rpc) -> None:
|
||||
account_id = rpc.add_account()
|
||||
|
||||
@@ -590,60 +735,6 @@ def test_mdn_doesnt_break_autocrypt(acfactory) -> None:
|
||||
assert snapshot.show_padlock
|
||||
|
||||
|
||||
def test_reaction_to_partially_fetched_msg(acfactory, tmp_path):
|
||||
"""See https://github.com/deltachat/deltachat-core-rust/issues/3688 "Partially downloaded
|
||||
messages are received out of order".
|
||||
|
||||
If the Inbox contains X small messages followed by Y large messages followed by Z small
|
||||
messages, Delta Chat first downloaded a batch of X+Z messages, and then a batch of Y messages.
|
||||
|
||||
This bug was discovered by @Simon-Laux while testing reactions PR #3644 and can be reproduced
|
||||
with online test as follows:
|
||||
- Bob enables download limit and goes offline.
|
||||
- Alice sends a large message to Bob and reacts to this message with a thumbs-up.
|
||||
- Bob goes online
|
||||
- Bob first processes a reaction message and throws it away because there is no corresponding
|
||||
message, then processes a partially downloaded message.
|
||||
- As a result, Bob does not see a reaction
|
||||
"""
|
||||
download_limit = 300000
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
ac1_addr = ac1.get_config("addr")
|
||||
chat = ac1.create_chat(ac2)
|
||||
ac2.set_config("download_limit", str(download_limit))
|
||||
ac2.stop_io()
|
||||
|
||||
logging.info("sending small+large messages from ac1 to ac2")
|
||||
msgs = []
|
||||
msgs.append(chat.send_text("hi"))
|
||||
path = tmp_path / "large"
|
||||
path.write_bytes(os.urandom(download_limit + 1))
|
||||
msgs.append(chat.send_file(str(path)))
|
||||
for m in msgs:
|
||||
m.wait_until_delivered()
|
||||
|
||||
logging.info("sending a reaction to the large message from ac1 to ac2")
|
||||
# TODO: Find the reason of an occasional message reordering on the server (so that the reaction
|
||||
# has a lower UID than the previous message). W/a is to sleep for some time to let the reaction
|
||||
# have a later INTERNALDATE.
|
||||
time.sleep(1.1)
|
||||
react_str = "\N{THUMBS UP SIGN}"
|
||||
msgs.append(msgs[-1].send_reaction(react_str))
|
||||
msgs[-1].wait_until_delivered()
|
||||
|
||||
ac2.start_io()
|
||||
|
||||
logging.info("wait for ac2 to receive a reaction")
|
||||
msg2 = Message(ac2, ac2.wait_for_reactions_changed().msg_id)
|
||||
assert msg2.get_sender_contact().get_snapshot().address == ac1_addr
|
||||
assert msg2.get_snapshot().download_state == DownloadState.AVAILABLE
|
||||
reactions = msg2.get_reactions()
|
||||
contacts = [Contact(ac2, int(i)) for i in reactions.reactions_by_contact]
|
||||
assert len(contacts) == 1
|
||||
assert contacts[0].get_snapshot().address == ac1_addr
|
||||
assert list(reactions.reactions_by_contact.values())[0] == [react_str]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("n_accounts", [3, 2])
|
||||
def test_download_limit_chat_assignment(acfactory, tmp_path, n_accounts):
|
||||
download_limit = 300000
|
||||
@@ -660,8 +751,6 @@ def test_download_limit_chat_assignment(acfactory, tmp_path, n_accounts):
|
||||
contact = alice.create_contact(account)
|
||||
alice_group.add_contact(contact)
|
||||
|
||||
if n_accounts == 2:
|
||||
bob_chat_alice = bob.create_chat(alice)
|
||||
bob.set_config("download_limit", str(download_limit))
|
||||
|
||||
alice_group.send_text("hi")
|
||||
@@ -672,20 +761,157 @@ def test_download_limit_chat_assignment(acfactory, tmp_path, n_accounts):
|
||||
path = tmp_path / "large"
|
||||
path.write_bytes(os.urandom(download_limit + 1))
|
||||
|
||||
n_done = 0
|
||||
for i in range(10):
|
||||
logging.info("Sending message %s", i)
|
||||
alice_group.send_file(str(path))
|
||||
snapshot = bob.wait_for_incoming_msg().get_snapshot()
|
||||
assert snapshot.download_state == DownloadState.AVAILABLE
|
||||
if n_accounts > 2:
|
||||
assert snapshot.chat == bob_group
|
||||
if snapshot.download_state == DownloadState.DONE:
|
||||
n_done += 1
|
||||
# Work around lost and reordered pre-messages.
|
||||
assert n_done <= 1
|
||||
else:
|
||||
# Group contains only Alice and Bob,
|
||||
# so partially downloaded messages are
|
||||
# hard to distinguish from private replies to group messages.
|
||||
#
|
||||
# Message may be a private reply, so we assign it to 1:1 chat with Alice.
|
||||
assert snapshot.chat == bob_chat_alice
|
||||
assert snapshot.download_state == DownloadState.AVAILABLE
|
||||
assert snapshot.chat == bob_group
|
||||
|
||||
|
||||
def test_download_small_msg_first(acfactory, tmp_path):
|
||||
download_limit = 70000
|
||||
|
||||
alice, bob0 = acfactory.get_online_accounts(2)
|
||||
bob1 = bob0.clone()
|
||||
bob1.set_config("download_limit", str(download_limit))
|
||||
|
||||
chat = alice.create_chat(bob0)
|
||||
path = tmp_path / "large_enough"
|
||||
path.write_bytes(os.urandom(download_limit + 1))
|
||||
# Less than 140K, so sent w/o a pre-message.
|
||||
chat.send_file(str(path))
|
||||
chat.send_text("hi")
|
||||
bob0.create_chat(alice)
|
||||
assert bob0.wait_for_incoming_msg().get_snapshot().text == ""
|
||||
assert bob0.wait_for_incoming_msg().get_snapshot().text == "hi"
|
||||
|
||||
bob1.start_io()
|
||||
bob1.create_chat(alice)
|
||||
assert bob1.wait_for_incoming_msg().get_snapshot().text == "hi"
|
||||
assert bob1.wait_for_incoming_msg().get_snapshot().text == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("delete_chat", [False, True])
|
||||
def test_delete_available_msg(acfactory, tmp_path, direct_imap, delete_chat):
|
||||
"""
|
||||
Tests `DownloadState.AVAILABLE` message deletion on the receiver side.
|
||||
Also tests pre- and post-message deletion on the sender side.
|
||||
"""
|
||||
# Min. UI setting as of v2.35
|
||||
download_limit = 163840
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
bob.set_config("download_limit", str(download_limit))
|
||||
# Avoid immediate deletion from the server
|
||||
alice.set_config("bcc_self", "1")
|
||||
bob.set_config("bcc_self", "1")
|
||||
|
||||
chat_alice = alice.create_chat(bob)
|
||||
path = tmp_path / "large"
|
||||
path.write_bytes(os.urandom(download_limit + 1))
|
||||
msg_alice = chat_alice.send_file(str(path))
|
||||
msg_bob = bob.wait_for_incoming_msg()
|
||||
msg_bob_snapshot = msg_bob.get_snapshot()
|
||||
assert msg_bob_snapshot.download_state == DownloadState.AVAILABLE
|
||||
chat_bob = bob.get_chat_by_id(msg_bob_snapshot.chat_id)
|
||||
|
||||
# Avoid DeleteMessages sync message
|
||||
bob.set_config("bcc_self", "0")
|
||||
if delete_chat:
|
||||
chat_bob.delete()
|
||||
else:
|
||||
bob.delete_messages([msg_bob])
|
||||
alice.wait_for_event(EventType.SMTP_MESSAGE_SENT)
|
||||
alice.wait_for_event(EventType.SMTP_MESSAGE_SENT)
|
||||
alice.set_config("bcc_self", "0")
|
||||
if delete_chat:
|
||||
chat_alice.delete()
|
||||
else:
|
||||
alice.delete_messages([msg_alice])
|
||||
for acc in [bob, alice]:
|
||||
if not delete_chat:
|
||||
acc.wait_for_event(EventType.MSG_DELETED)
|
||||
acc_direct_imap = direct_imap(acc)
|
||||
# Messages may be deleted separately
|
||||
while True:
|
||||
acc.wait_for_event(EventType.IMAP_MESSAGE_DELETED)
|
||||
while True:
|
||||
event = acc.wait_for_event()
|
||||
if event.kind == EventType.INFO and "Close/expunge succeeded." in event.msg:
|
||||
break
|
||||
if len(acc_direct_imap.get_all_messages()) == 0:
|
||||
break
|
||||
|
||||
|
||||
def test_delete_fully_downloaded_msg(acfactory, tmp_path, direct_imap):
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
# Avoid immediate deletion from the server
|
||||
bob.set_config("bcc_self", "1")
|
||||
|
||||
chat_alice = alice.create_chat(bob)
|
||||
path = tmp_path / "large"
|
||||
# Big enough to be sent with a pre-message
|
||||
path.write_bytes(os.urandom(300000))
|
||||
chat_alice.send_file(str(path))
|
||||
|
||||
msg = bob.wait_for_incoming_msg()
|
||||
msg_snapshot = msg.get_snapshot()
|
||||
assert msg_snapshot.download_state == DownloadState.AVAILABLE
|
||||
msgs_changed_event = bob.wait_for_msgs_changed_event()
|
||||
assert msgs_changed_event.msg_id == msg.id
|
||||
msg_snapshot = msg.get_snapshot()
|
||||
assert msg_snapshot.download_state == DownloadState.DONE
|
||||
|
||||
bob_direct_imap = direct_imap(bob)
|
||||
assert len(bob_direct_imap.get_all_messages()) == 2
|
||||
# Avoid DeleteMessages sync message
|
||||
bob.set_config("bcc_self", "0")
|
||||
bob.delete_messages([msg])
|
||||
bob.wait_for_event(EventType.MSG_DELETED)
|
||||
# Messages may be deleted separately
|
||||
while True:
|
||||
bob.wait_for_event(EventType.IMAP_MESSAGE_DELETED)
|
||||
while True:
|
||||
event = bob.wait_for_event()
|
||||
if event.kind == EventType.INFO and "Close/expunge succeeded." in event.msg:
|
||||
break
|
||||
if len(bob_direct_imap.get_all_messages()) == 0:
|
||||
break
|
||||
|
||||
|
||||
def test_imap_autodelete_fully_downloaded_msg(acfactory, tmp_path, direct_imap):
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
chat_alice = alice.create_chat(bob)
|
||||
path = tmp_path / "large"
|
||||
# Big enough to be sent with a pre-message
|
||||
path.write_bytes(os.urandom(300000))
|
||||
chat_alice.send_file(str(path))
|
||||
|
||||
msg = bob.wait_for_incoming_msg()
|
||||
msg_snapshot = msg.get_snapshot()
|
||||
assert msg_snapshot.download_state == DownloadState.AVAILABLE
|
||||
msgs_changed_event = bob.wait_for_msgs_changed_event()
|
||||
assert msgs_changed_event.msg_id == msg.id
|
||||
msg_snapshot = msg.get_snapshot()
|
||||
assert msg_snapshot.download_state == DownloadState.DONE
|
||||
|
||||
bob_direct_imap = direct_imap(bob)
|
||||
# Messages may be deleted separately
|
||||
while True:
|
||||
if len(bob_direct_imap.get_all_messages()) == 0:
|
||||
break
|
||||
bob.wait_for_event(EventType.IMAP_MESSAGE_DELETED)
|
||||
while True:
|
||||
event = bob.wait_for_event()
|
||||
if event.kind == EventType.INFO and "Close/expunge succeeded." in event.msg:
|
||||
break
|
||||
|
||||
|
||||
def test_markseen_contact_request(acfactory):
|
||||
@@ -711,6 +937,47 @@ def test_markseen_contact_request(acfactory):
|
||||
assert message2.get_snapshot().state == MessageState.IN_SEEN
|
||||
|
||||
|
||||
@pytest.mark.parametrize("team_profile", [True, False])
|
||||
def test_no_markseen_in_team_profile(team_profile, acfactory):
|
||||
"""
|
||||
Test that seen status is synchronized iff `team_profile` isn't set.
|
||||
"""
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
if team_profile:
|
||||
bob.set_config("team_profile", "1")
|
||||
|
||||
# Bob sets up a second device.
|
||||
bob2 = bob.clone()
|
||||
bob2.start_io()
|
||||
|
||||
alice_chat_bob = alice.create_chat(bob)
|
||||
bob_chat_alice = bob.create_chat(alice)
|
||||
bob2.create_chat(alice)
|
||||
alice_chat_bob.send_text("Hello Bob!")
|
||||
|
||||
message = bob.wait_for_incoming_msg()
|
||||
message2 = bob2.wait_for_incoming_msg()
|
||||
assert message2.get_snapshot().state == MessageState.IN_FRESH
|
||||
|
||||
message.mark_seen()
|
||||
|
||||
# Send a message and wait until it arrives
|
||||
# in order to wait until Bob2 gets the markseen message.
|
||||
# This also tests that outgoing messages
|
||||
# don't mark preceeding messages as seen in team profiles.
|
||||
bob_chat_alice.send_text("Outgoing message")
|
||||
while True:
|
||||
outgoing = bob2.wait_for_msg(EventType.MSGS_CHANGED)
|
||||
if outgoing.id != 0:
|
||||
break
|
||||
assert outgoing.get_snapshot().text == "Outgoing message"
|
||||
|
||||
if team_profile:
|
||||
assert message2.get_snapshot().state == MessageState.IN_FRESH
|
||||
else:
|
||||
assert message2.get_snapshot().state == MessageState.IN_SEEN
|
||||
|
||||
|
||||
def test_read_receipt(acfactory):
|
||||
"""
|
||||
Test sending a read receipt and ensure it is attributed to the correct contact.
|
||||
@@ -730,6 +997,9 @@ def test_read_receipt(acfactory):
|
||||
assert len(read_receipts) == 1
|
||||
assert read_receipts[0].contact_id == alice_contact_bob.id
|
||||
|
||||
read_receipt_cnt = read_msg.get_read_receipt_count()
|
||||
assert read_receipt_cnt == 1
|
||||
|
||||
|
||||
def test_get_http_response(acfactory):
|
||||
alice = acfactory.new_configured_account()
|
||||
@@ -742,7 +1012,7 @@ def test_configured_imap_certificate_checks(acfactory):
|
||||
alice = acfactory.new_configured_account()
|
||||
|
||||
# Certificate checks should be configured (not None)
|
||||
assert "cert_strict" in alice.get_info().used_account_settings
|
||||
assert "cert_strict" in alice.get_info().used_transport_settings
|
||||
|
||||
# "cert_old_automatic" is the value old Delta Chat core versions used
|
||||
# to mean user entered "imap_certificate_checks=0" (Automatic)
|
||||
@@ -755,7 +1025,7 @@ def test_configured_imap_certificate_checks(acfactory):
|
||||
#
|
||||
# Core 1.142.4, 1.142.5 and 1.142.6 saved this value due to bug.
|
||||
# This test is a regression test to prevent this happening again.
|
||||
assert "cert_old_automatic" not in alice.get_info().used_account_settings
|
||||
assert "cert_old_automatic" not in alice.get_info().used_transport_settings
|
||||
|
||||
|
||||
def test_no_old_msg_is_fresh(acfactory):
|
||||
@@ -867,15 +1137,15 @@ def test_leave_broadcast(acfactory, all_devices_online):
|
||||
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
|
||||
|
||||
if please_wait_info_msg:
|
||||
first_msg = chat_msgs.pop(0).get_snapshot()
|
||||
assert "invited you to join this channel" in first_msg.text
|
||||
assert first_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."
|
||||
@@ -933,6 +1203,30 @@ def test_leave_broadcast(acfactory, all_devices_online):
|
||||
check_account(bob2, bob2.create_contact(alice), inviter_side=False)
|
||||
|
||||
|
||||
def test_leave_and_delete_group(acfactory, log):
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
log.section("Alice creates a group")
|
||||
alice_chat = alice.create_group("Group")
|
||||
alice_chat.add_contact(bob)
|
||||
assert len(alice_chat.get_contacts()) == 2 # Alice and Bob
|
||||
alice_chat.send_text("hello")
|
||||
|
||||
log.section("Bob sees the group, and leaves and deletes it")
|
||||
msg = bob.wait_for_incoming_msg().get_snapshot()
|
||||
assert msg.text == "hello"
|
||||
msg.chat.accept()
|
||||
|
||||
msg.chat.leave()
|
||||
# Bob deletes the chat. This must not prevent the leave message from being sent.
|
||||
msg.chat.delete()
|
||||
|
||||
log.section("Alice receives the delete message")
|
||||
# After Bob left, only Alice will be left in the group:
|
||||
while len(alice_chat.get_contacts()) != 1:
|
||||
alice.wait_for_event(EventType.CHAT_MODIFIED)
|
||||
|
||||
|
||||
def test_immediate_autodelete(acfactory, direct_imap, log):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
|
||||
@@ -1002,3 +1296,86 @@ def test_background_fetch(acfactory, dc):
|
||||
snapshot = messages[-1].get_snapshot()
|
||||
if snapshot.text == "Hello again!":
|
||||
break
|
||||
|
||||
|
||||
def test_message_exists(acfactory):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
chat = ac1.create_chat(ac2)
|
||||
message1 = chat.send_text("Hello!")
|
||||
message2 = chat.send_text("Hello again!")
|
||||
assert message1.exists()
|
||||
assert message2.exists()
|
||||
|
||||
ac1.delete_messages([message1])
|
||||
assert not message1.exists()
|
||||
assert message2.exists()
|
||||
|
||||
# There is no error when checking if
|
||||
# the message exists for deleted account.
|
||||
ac1.remove()
|
||||
assert not message1.exists()
|
||||
assert not message2.exists()
|
||||
|
||||
|
||||
def test_synchronize_member_list_on_group_rejoin(acfactory, log):
|
||||
"""
|
||||
Test that user recreates group member list when it joins the group again.
|
||||
ac1 creates a group with two other accounts: ac2 and ac3
|
||||
Then it removes ac2, removes ac3 and adds ac2 back.
|
||||
ac2 did not see that ac3 is removed, so it should rebuild member list from scratch.
|
||||
"""
|
||||
log.section("setting up accounts, accepted with each other")
|
||||
ac1, ac2, ac3 = accounts = acfactory.get_online_accounts(3)
|
||||
|
||||
log.section("ac1: creating group chat with 2 other members")
|
||||
chat = ac1.create_group("title1")
|
||||
chat.add_contact(ac2)
|
||||
chat.add_contact(ac3)
|
||||
|
||||
log.section("ac1: send message to new group chat")
|
||||
msg = chat.send_text("hello")
|
||||
assert chat.num_contacts() == 3
|
||||
|
||||
log.section("checking that the chat arrived correctly")
|
||||
for ac in accounts[1:]:
|
||||
msg = ac.wait_for_incoming_msg().get_snapshot()
|
||||
assert msg.text == "hello"
|
||||
assert msg.chat.num_contacts() == 3
|
||||
msg.chat.accept()
|
||||
|
||||
log.section("ac1: removing ac2")
|
||||
chat.remove_contact(ac2)
|
||||
|
||||
log.section("ac2: wait for a message about removal from the chat")
|
||||
ac2.wait_for_incoming_msg()
|
||||
log.section("ac1: removing ac3")
|
||||
chat.remove_contact(ac3)
|
||||
|
||||
log.section("ac1: adding ac2 back")
|
||||
chat.add_contact(ac2)
|
||||
|
||||
log.section("ac2: check that ac3 is removed")
|
||||
msg = ac2.wait_for_incoming_msg()
|
||||
|
||||
assert chat.num_contacts() == 2
|
||||
assert msg.get_snapshot().chat.num_contacts() == 2
|
||||
|
||||
|
||||
def test_large_message(acfactory) -> None:
|
||||
"""
|
||||
Test sending large message without download limit set,
|
||||
so it is sent with pre-message but downloaded without user interaction.
|
||||
"""
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
alice_chat_bob = alice.create_chat(bob)
|
||||
alice_chat_bob.send_message(
|
||||
"Hello World, this message is bigger than 5 bytes",
|
||||
file="../test-data/image/screenshot.jpg",
|
||||
)
|
||||
|
||||
msg = bob.wait_for_incoming_msg()
|
||||
msgs_changed_event = bob.wait_for_msgs_changed_event()
|
||||
assert msg.id == msgs_changed_event.msg_id
|
||||
snapshot = msg.get_snapshot()
|
||||
assert snapshot.text == "Hello World, this message is bigger than 5 bytes"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-rpc-server"
|
||||
version = "2.27.0"
|
||||
version = "2.46.0-dev"
|
||||
description = "DeltaChat JSON-RPC server"
|
||||
edition = "2021"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "index.d.ts",
|
||||
"version": "2.27.0"
|
||||
"version": "2.46.0-dev"
|
||||
}
|
||||
|
||||
@@ -24,6 +24,14 @@ use yerpc::{RpcClient, RpcSession};
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() {
|
||||
// Logs from `log` crate and traces from `tracing` crate
|
||||
// are configurable with `RUST_LOG` environment variable
|
||||
// and go to stderr to avoid interfering with JSON-RPC using stdout.
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::from_default_env())
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
|
||||
let r = main_impl().await;
|
||||
// From tokio documentation:
|
||||
// "For technical reasons, stdin is implemented by using an ordinary blocking read on a separate
|
||||
@@ -43,7 +51,7 @@ async fn main_impl() -> Result<()> {
|
||||
if let Some(arg) = args.next() {
|
||||
return Err(anyhow!("Unrecognized argument {arg:?}"));
|
||||
}
|
||||
eprintln!("{}", &*DC_VERSION_STR);
|
||||
eprintln!("{DC_VERSION_STR}");
|
||||
return Ok(());
|
||||
} else if first_arg.to_str() == Some("--openrpc") {
|
||||
if let Some(arg) = args.next() {
|
||||
@@ -64,14 +72,6 @@ async fn main_impl() -> Result<()> {
|
||||
#[cfg(target_family = "unix")]
|
||||
let mut sigterm = signal_unix::signal(signal_unix::SignalKind::terminate())?;
|
||||
|
||||
// Logs from `log` crate and traces from `tracing` crate
|
||||
// are configurable with `RUST_LOG` environment variable
|
||||
// and go to stderr to avoid interfering with JSON-RPC using stdout.
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::from_default_env())
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
|
||||
let path = std::env::var("DC_ACCOUNTS_PATH").unwrap_or_else(|_| "accounts".to_string());
|
||||
log::info!("Starting with accounts directory `{path}`.");
|
||||
let writable = true;
|
||||
|
||||
10
deny.toml
10
deny.toml
@@ -12,6 +12,11 @@ ignore = [
|
||||
|
||||
# Unmaintained paste
|
||||
"RUSTSEC-2024-0436",
|
||||
|
||||
# Unmaintained rustls-pemfile
|
||||
# It is a transitive dependency of iroh 0.35.0,
|
||||
# this should be fixed by upgrading to iroh 1.0 once it is released.
|
||||
"RUSTSEC-2025-0134",
|
||||
]
|
||||
|
||||
[bans]
|
||||
@@ -26,11 +31,10 @@ skip = [
|
||||
{ name = "derive_more", version = "1.0.0" },
|
||||
{ name = "event-listener", version = "2.5.3" },
|
||||
{ name = "getrandom", version = "0.2.12" },
|
||||
{ name = "hashbrown", version = "0.14.5" },
|
||||
{ name = "heck", version = "0.4.1" },
|
||||
{ name = "http", version = "0.2.12" },
|
||||
{ name = "linux-raw-sys", version = "0.4.14" },
|
||||
{ name = "lru", version = "0.12.3" },
|
||||
{ name = "lru", version = "0.12.5" },
|
||||
{ name = "netlink-packet-route", version = "0.17.1" },
|
||||
{ name = "nom", version = "7.1.3" },
|
||||
{ name = "rand_chacha", version = "0.3.1" },
|
||||
@@ -38,6 +42,7 @@ skip = [
|
||||
{ name = "rand", version = "0.8.5" },
|
||||
{ name = "rustix", version = "0.38.44" },
|
||||
{ name = "serdect", version = "0.2.0" },
|
||||
{ name = "socket2", version = "0.5.9" },
|
||||
{ name = "spin", version = "0.9.8" },
|
||||
{ name = "strum_macros", version = "0.26.2" },
|
||||
{ name = "strum", version = "0.26.2" },
|
||||
@@ -62,7 +67,6 @@ skip = [
|
||||
{ name = "windows_x86_64_gnu" },
|
||||
{ name = "windows_x86_64_gnullvm" },
|
||||
{ name = "windows_x86_64_msvc" },
|
||||
{ name = "zerocopy", version = "0.7.32" },
|
||||
]
|
||||
|
||||
|
||||
|
||||
18
flake.lock
generated
18
flake.lock
generated
@@ -47,11 +47,11 @@
|
||||
"rust-analyzer-src": "rust-analyzer-src"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1747291057,
|
||||
"narHash": "sha256-9Wir6aLJAeJKqdoQUiwfKdBn7SyNXTJGRSscRyVOo2Y=",
|
||||
"lastModified": 1763361733,
|
||||
"narHash": "sha256-ka7dpwH3HIXCyD2wl5F7cPLeRbqZoY2ullALsvxdPt8=",
|
||||
"owner": "nix-community",
|
||||
"repo": "fenix",
|
||||
"rev": "76ffc1b7b3ec8078fe01794628b6abff35cbda8f",
|
||||
"rev": "6c8d48e3b0ae371b19ac1485744687b788e80193",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -147,11 +147,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1747179050,
|
||||
"narHash": "sha256-qhFMmDkeJX9KJwr5H32f1r7Prs7XbQWtO0h3V0a0rFY=",
|
||||
"lastModified": 1762977756,
|
||||
"narHash": "sha256-4PqRErxfe+2toFJFgcRKZ0UI9NSIOJa+7RXVtBhy4KE=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "adaa24fbf46737f3f1b5497bf64bae750f82942e",
|
||||
"rev": "c5ae371f1a6a7fd27823bc500d9390b38c05fa55",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -202,11 +202,11 @@
|
||||
"rust-analyzer-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1746889290,
|
||||
"narHash": "sha256-h3LQYZgyv2l3U7r+mcsrEOGRldaK0zJFwAAva4hV/6g=",
|
||||
"lastModified": 1762860488,
|
||||
"narHash": "sha256-rMfWMCOo/pPefM2We0iMBLi2kLBAnYoB9thi4qS7uk4=",
|
||||
"owner": "rust-lang",
|
||||
"repo": "rust-analyzer",
|
||||
"rev": "2bafe9d96c6734aacfd49e115f6cf61e7adc68bc",
|
||||
"rev": "2efc80078029894eec0699f62ec8d5c1a56af763",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
23
flake.nix
23
flake.nix
@@ -1,5 +1,5 @@
|
||||
{
|
||||
description = "Delta Chat core";
|
||||
description = "Chatmail core";
|
||||
inputs = {
|
||||
fenix.url = "github:nix-community/fenix";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
@@ -14,7 +14,15 @@
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
inherit (pkgs.stdenv) isDarwin;
|
||||
fenixPkgs = fenix.packages.${system};
|
||||
naersk' = pkgs.callPackage naersk { };
|
||||
fenixToolchain = fenixPkgs.combine [
|
||||
fenixPkgs.stable.rustc
|
||||
fenixPkgs.stable.cargo
|
||||
fenixPkgs.stable.rust-std
|
||||
];
|
||||
naersk' = pkgs.callPackage naersk {
|
||||
cargo = fenixToolchain;
|
||||
rustc = fenixToolchain;
|
||||
};
|
||||
manifest = (pkgs.lib.importTOML ./Cargo.toml).package;
|
||||
androidSdk = android.sdk.${system} (sdkPkgs:
|
||||
builtins.attrValues {
|
||||
@@ -470,6 +478,12 @@
|
||||
};
|
||||
|
||||
libdeltachat =
|
||||
let
|
||||
rustPlatform = (pkgs.makeRustPlatform {
|
||||
cargo = fenixToolchain;
|
||||
rustc = fenixToolchain;
|
||||
});
|
||||
in
|
||||
pkgs.stdenv.mkDerivation {
|
||||
pname = "libdeltachat";
|
||||
version = manifest.version;
|
||||
@@ -479,8 +493,9 @@
|
||||
nativeBuildInputs = [
|
||||
pkgs.perl # Needed to build vendored OpenSSL.
|
||||
pkgs.cmake
|
||||
pkgs.rustPlatform.cargoSetupHook
|
||||
pkgs.cargo
|
||||
rustPlatform.cargoSetupHook
|
||||
fenixPkgs.stable.rustc
|
||||
fenixPkgs.stable.cargo
|
||||
];
|
||||
|
||||
postInstall = ''
|
||||
|
||||
@@ -14,6 +14,7 @@ def datadir():
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.skip("The test is flaky in CI and crashes the interpreter as of 2025-11-12")
|
||||
def test_echo_quit_plugin(acfactory, lp):
|
||||
lp.sec("creating one echo_and_quit bot")
|
||||
botproc = acfactory.run_bot_process(echo_and_quit)
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=45", "wheel", "cffi>=1.0.0", "pkgconfig"]
|
||||
requires = ["setuptools>=77", "wheel", "cffi>=1.0.0", "pkgconfig"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "deltachat"
|
||||
version = "2.27.0"
|
||||
version = "2.46.0-dev"
|
||||
license = "MPL-2.0"
|
||||
description = "Python bindings for the Delta Chat Core library using CFFI against the Rust-implemented libdeltachat"
|
||||
readme = "README.rst"
|
||||
requires-python = ">=3.8"
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
{ name = "holger krekel, Floris Bruynooghe, Bjoern Petersen and contributors" },
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Topic :: Communications :: Chat",
|
||||
"Topic :: Communications :: Email",
|
||||
@@ -23,7 +23,6 @@ classifiers = [
|
||||
dependencies = [
|
||||
"cffi>=1.0.0",
|
||||
"imap-tools",
|
||||
"importlib_metadata;python_version<'3.8'",
|
||||
"pluggy",
|
||||
"requests",
|
||||
]
|
||||
|
||||
@@ -522,7 +522,6 @@ class ACFactory:
|
||||
ac = self.get_unconfigured_account()
|
||||
assert "addr" in configdict and "mail_pw" in configdict, configdict
|
||||
configdict.setdefault("bcc_self", False)
|
||||
configdict.setdefault("mvbox_move", False)
|
||||
configdict.setdefault("sync_msgs", False)
|
||||
configdict.setdefault("delete_server_after", 0)
|
||||
ac.update_config(configdict)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import sys
|
||||
import time
|
||||
|
||||
import deltachat as dc
|
||||
@@ -63,56 +62,6 @@ class TestGroupStressTests:
|
||||
# Message should be encrypted because keys of other members are gossiped
|
||||
assert msg.is_encrypted()
|
||||
|
||||
def test_synchronize_member_list_on_group_rejoin(self, acfactory, lp):
|
||||
"""
|
||||
Test that user recreates group member list when it joins the group again.
|
||||
ac1 creates a group with two other accounts: ac2 and ac3
|
||||
Then it removes ac2, removes ac3 and adds ac2 back.
|
||||
ac2 did not see that ac3 is removed, so it should rebuild member list from scratch.
|
||||
"""
|
||||
lp.sec("setting up accounts, accepted with each other")
|
||||
accounts = acfactory.get_online_accounts(3)
|
||||
acfactory.introduce_each_other(accounts)
|
||||
ac1, ac2, ac3 = accounts
|
||||
|
||||
lp.sec("ac1: creating group chat with 2 other members")
|
||||
chat = ac1.create_group_chat("title1", contacts=[ac2, ac3])
|
||||
assert not chat.is_promoted()
|
||||
|
||||
lp.sec("ac1: send message to new group chat")
|
||||
msg = chat.send_text("hello")
|
||||
assert chat.is_promoted() and msg.is_encrypted()
|
||||
|
||||
assert chat.num_contacts() == 3
|
||||
|
||||
lp.sec("checking that the chat arrived correctly")
|
||||
for ac in accounts[1:]:
|
||||
msg = ac._evtracker.wait_next_incoming_message()
|
||||
assert msg.text == "hello"
|
||||
print("chat is", msg.chat)
|
||||
assert msg.chat.num_contacts() == 3
|
||||
|
||||
lp.sec("ac1: removing ac2")
|
||||
chat.remove_contact(ac2)
|
||||
|
||||
lp.sec("ac2: wait for a message about removal from the chat")
|
||||
msg = ac2._evtracker.wait_next_incoming_message()
|
||||
|
||||
lp.sec("ac1: removing ac3")
|
||||
chat.remove_contact(ac3)
|
||||
|
||||
lp.sec("ac1: adding ac2 back")
|
||||
# Group is promoted, message is sent automatically
|
||||
assert chat.is_promoted()
|
||||
chat.add_contact(ac2)
|
||||
|
||||
lp.sec("ac2: check that ac3 is removed")
|
||||
msg = ac2._evtracker.wait_next_incoming_message()
|
||||
|
||||
assert chat.num_contacts() == 2
|
||||
assert msg.chat.num_contacts() == 2
|
||||
acfactory.dump_imap_summary(sys.stdout)
|
||||
|
||||
|
||||
def test_qr_verified_group_and_chatting(acfactory, lp):
|
||||
ac1, ac2, ac3 = acfactory.get_online_accounts(3)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
@@ -9,7 +8,6 @@ from imap_tools import AND
|
||||
|
||||
import deltachat as dc
|
||||
from deltachat import account_hookimpl, Message
|
||||
from deltachat.tracker import ImexTracker
|
||||
from deltachat.testplugin import E2EE_INFO_MSGS
|
||||
|
||||
|
||||
@@ -222,71 +220,6 @@ def test_webxdc_huge_update(acfactory, data, lp):
|
||||
assert update["payload"] == payload
|
||||
|
||||
|
||||
def test_webxdc_download_on_demand(acfactory, data, lp):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
acfactory.introduce_each_other([ac1, ac2])
|
||||
chat = acfactory.get_accepted_chat(ac1, ac2)
|
||||
|
||||
msg1 = Message.new_empty(ac1, "webxdc")
|
||||
msg1.set_text("message1")
|
||||
msg1.set_file(data.get_path("webxdc/minimal.xdc"))
|
||||
msg1 = chat.send_msg(msg1)
|
||||
assert msg1.is_webxdc()
|
||||
assert msg1.filename
|
||||
|
||||
msg2 = ac2._evtracker.wait_next_incoming_message()
|
||||
assert msg2.is_webxdc()
|
||||
|
||||
lp.sec("ac2 sets download limit")
|
||||
ac2.set_config("download_limit", "100")
|
||||
assert msg1.send_status_update({"payload": base64.b64encode(os.urandom(300000))}, "some test data")
|
||||
ac2_update = ac2._evtracker.wait_next_incoming_message()
|
||||
assert ac2_update.download_state == dc.const.DC_DOWNLOAD_AVAILABLE
|
||||
assert not msg2.get_status_updates()
|
||||
|
||||
ac2_update.download_full()
|
||||
ac2._evtracker.get_matching("DC_EVENT_WEBXDC_STATUS_UPDATE")
|
||||
assert msg2.get_status_updates()
|
||||
|
||||
# Get a event notifying that the message disappeared from the chat.
|
||||
msgs_changed_event = ac2._evtracker.get_matching("DC_EVENT_MSGS_CHANGED")
|
||||
assert msgs_changed_event.data1 == msg2.chat.id
|
||||
assert msgs_changed_event.data2 == 0
|
||||
|
||||
|
||||
def test_enable_mvbox_move(acfactory, lp):
|
||||
(ac1,) = acfactory.get_online_accounts(1)
|
||||
|
||||
lp.sec("ac2: start without mvbox thread")
|
||||
ac2 = acfactory.new_online_configuring_account(mvbox_move=False)
|
||||
acfactory.bring_accounts_online()
|
||||
|
||||
lp.sec("ac2: configuring mvbox")
|
||||
ac2.set_config("mvbox_move", "1")
|
||||
|
||||
lp.sec("ac1: send message and wait for ac2 to receive it")
|
||||
acfactory.get_accepted_chat(ac1, ac2).send_text("message1")
|
||||
assert ac2._evtracker.wait_next_incoming_message().text == "message1"
|
||||
|
||||
|
||||
def test_move_sync_msgs(acfactory):
|
||||
ac1 = acfactory.new_online_configuring_account(bcc_self=True, sync_msgs=True, fix_is_chatmail=True)
|
||||
acfactory.bring_accounts_online()
|
||||
|
||||
ac1.direct_imap.select_folder("DeltaChat")
|
||||
# Sync messages may also be sent during the configuration.
|
||||
mvbox_msg_cnt = len(ac1.direct_imap.get_all_messages())
|
||||
|
||||
ac1.set_config("displayname", "Alice")
|
||||
ac1._evtracker.get_matching("DC_EVENT_MSG_DELIVERED")
|
||||
ac1.set_config("displayname", "Bob")
|
||||
ac1._evtracker.get_matching("DC_EVENT_MSG_DELIVERED")
|
||||
ac1.direct_imap.select_folder("Inbox")
|
||||
assert len(ac1.direct_imap.get_all_messages()) == 0
|
||||
ac1.direct_imap.select_folder("DeltaChat")
|
||||
assert len(ac1.direct_imap.get_all_messages()) == mvbox_msg_cnt + 2
|
||||
|
||||
|
||||
def test_forward_messages(acfactory, lp):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
chat = ac1.create_chat(ac2)
|
||||
@@ -402,7 +335,7 @@ def test_long_group_name(acfactory, lp):
|
||||
|
||||
|
||||
def test_send_self_message(acfactory, lp):
|
||||
ac1 = acfactory.new_online_configuring_account(mvbox_move=True, bcc_self=True)
|
||||
ac1 = acfactory.new_online_configuring_account(bcc_self=True)
|
||||
acfactory.bring_accounts_online()
|
||||
lp.sec("ac1: create self chat")
|
||||
chat = ac1.get_self_contact().create_chat()
|
||||
@@ -561,7 +494,7 @@ def test_reply_privately(acfactory):
|
||||
|
||||
|
||||
def test_mdn_asymmetric(acfactory, lp):
|
||||
ac1 = acfactory.new_online_configuring_account(mvbox_move=True)
|
||||
ac1 = acfactory.new_online_configuring_account()
|
||||
ac2 = acfactory.new_online_configuring_account()
|
||||
acfactory.bring_accounts_online()
|
||||
|
||||
@@ -590,20 +523,14 @@ def test_mdn_asymmetric(acfactory, lp):
|
||||
ac2.mark_seen_messages([msg])
|
||||
|
||||
lp.sec("ac1: waiting for incoming activity")
|
||||
# MDN should be moved even though MDNs are already disabled
|
||||
ac1._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_MOVED")
|
||||
|
||||
assert len(chat.get_messages()) == 1 + E2EE_INFO_MSGS
|
||||
|
||||
# Wait for the message to be marked as seen on IMAP.
|
||||
ac1._evtracker.get_info_contains("Marked messages [0-9]+ in folder DeltaChat as seen.")
|
||||
ac1._evtracker.get_info_contains("Marked messages [0-9]+ in folder INBOX as seen.")
|
||||
|
||||
# MDN is received even though MDNs are already disabled
|
||||
assert msg_out.is_out_mdn_received()
|
||||
|
||||
ac1.direct_imap.select_config_folder("mvbox")
|
||||
assert len(list(ac1.direct_imap.conn.fetch(AND(seen=True)))) == 1
|
||||
|
||||
|
||||
def test_send_receive_encrypt(acfactory, lp):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
@@ -826,86 +753,6 @@ def test_send_and_receive_image(acfactory, lp, data):
|
||||
assert m == msg_in
|
||||
|
||||
|
||||
def test_import_export_online_all(acfactory, tmp_path, data, lp):
|
||||
(ac1, some1) = acfactory.get_online_accounts(2)
|
||||
|
||||
lp.sec("create some chat content")
|
||||
some1_addr = some1.get_config("addr")
|
||||
chat1 = ac1.create_contact(some1).create_chat()
|
||||
chat1.send_text("msg1")
|
||||
assert len(ac1.get_contacts()) == 1
|
||||
|
||||
original_image_path = data.get_path("d.png")
|
||||
chat1.send_image(original_image_path)
|
||||
|
||||
# Add another 100KB file that ensures that the progress is smooth enough
|
||||
path = tmp_path / "attachment.txt"
|
||||
with path.open("w") as file:
|
||||
file.truncate(100000)
|
||||
chat1.send_file(str(path))
|
||||
|
||||
def assert_account_is_proper(ac):
|
||||
contacts = ac.get_contacts()
|
||||
assert len(contacts) == 1
|
||||
contact2 = contacts[0]
|
||||
assert contact2.addr == some1_addr
|
||||
chat2 = contact2.create_chat()
|
||||
messages = chat2.get_messages()
|
||||
assert len(messages) == 3 + E2EE_INFO_MSGS
|
||||
assert messages[0 + E2EE_INFO_MSGS].text == "msg1"
|
||||
assert messages[1 + E2EE_INFO_MSGS].filemime == "image/png"
|
||||
assert os.stat(messages[1 + E2EE_INFO_MSGS].filename).st_size == os.stat(original_image_path).st_size
|
||||
ac.set_config("displayname", "new displayname")
|
||||
assert ac.get_config("displayname") == "new displayname"
|
||||
|
||||
assert_account_is_proper(ac1)
|
||||
|
||||
backupdir = tmp_path / "backup"
|
||||
backupdir.mkdir()
|
||||
|
||||
lp.sec(f"export all to {backupdir}")
|
||||
with ac1.temp_plugin(ImexTracker()) as imex_tracker:
|
||||
ac1.stop_io()
|
||||
ac1.imex(str(backupdir), dc.const.DC_IMEX_EXPORT_BACKUP)
|
||||
|
||||
# check progress events for export
|
||||
assert imex_tracker.wait_progress(1, progress_upper_limit=249)
|
||||
assert imex_tracker.wait_progress(250, progress_upper_limit=499)
|
||||
assert imex_tracker.wait_progress(500, progress_upper_limit=749)
|
||||
assert imex_tracker.wait_progress(750, progress_upper_limit=999)
|
||||
|
||||
paths = imex_tracker.wait_finish()
|
||||
assert len(paths) == 1
|
||||
path = paths[0]
|
||||
assert os.path.exists(path)
|
||||
ac1.start_io()
|
||||
|
||||
lp.sec("get fresh empty account")
|
||||
ac2 = acfactory.get_unconfigured_account()
|
||||
|
||||
lp.sec("get latest backup file")
|
||||
path2 = ac2.get_latest_backupfile(str(backupdir))
|
||||
assert path2 == path
|
||||
|
||||
lp.sec("import backup and check it's proper")
|
||||
with ac2.temp_plugin(ImexTracker()) as imex_tracker:
|
||||
ac2.import_all(path)
|
||||
|
||||
# check progress events for import
|
||||
assert imex_tracker.wait_progress(1, progress_upper_limit=249)
|
||||
assert imex_tracker.wait_progress(1000)
|
||||
|
||||
assert_account_is_proper(ac1)
|
||||
assert_account_is_proper(ac2)
|
||||
|
||||
lp.sec(f"Second-time export all to {backupdir}")
|
||||
ac1.stop_io()
|
||||
path2 = ac1.export_all(str(backupdir))
|
||||
assert os.path.exists(path2)
|
||||
assert path2 != path
|
||||
assert ac2.get_latest_backupfile(str(backupdir)) == path2
|
||||
|
||||
|
||||
def test_qr_email_capitalization(acfactory, lp):
|
||||
"""Regression test for a bug
|
||||
that resulted in failure to propagate verification
|
||||
@@ -1085,7 +932,6 @@ def test_set_get_group_image(acfactory, data, lp):
|
||||
|
||||
def test_connectivity(acfactory, lp):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
ac1.set_config("scan_all_folders_debounce_secs", "0")
|
||||
|
||||
ac1._evtracker.wait_for_connectivity(dc.const.DC_CONNECTIVITY_CONNECTED)
|
||||
|
||||
@@ -1295,16 +1141,17 @@ def test_configure_error_msgs_invalid_server(acfactory):
|
||||
ev = ac2._evtracker.get_matching("DC_EVENT_CONFIGURE_PROGRESS")
|
||||
if ev.data1 == 0:
|
||||
break
|
||||
err_lower = ev.data2.lower()
|
||||
# Can't connect so it probably should say something about "internet"
|
||||
# again, should not repeat itself
|
||||
# If this fails then probably `e.msg.to_lowercase().contains("could not resolve")`
|
||||
# in configure.rs returned false because the error message was changed
|
||||
# (i.e. did not contain "could not resolve" anymore)
|
||||
assert (ev.data2.count("internet") + ev.data2.count("network")) == 1
|
||||
assert (err_lower.count("internet") + err_lower.count("network")) == 1
|
||||
# Should mention that it can't connect:
|
||||
assert ev.data2.count("connect") == 1
|
||||
assert err_lower.count("connect") == 1
|
||||
# The users do not know what "configuration" is
|
||||
assert "configuration" not in ev.data2.lower()
|
||||
assert "configuration" not in err_lower
|
||||
|
||||
|
||||
def test_status(acfactory):
|
||||
|
||||
@@ -35,7 +35,7 @@ class TestOfflineAccountBasic:
|
||||
d = ac1.get_info()
|
||||
assert d["arch"]
|
||||
assert d["number_of_chats"] == "0"
|
||||
assert d["bcc_self"] == "1"
|
||||
assert d["bcc_self"] == "0"
|
||||
|
||||
def test_is_not_configured(self, acfactory):
|
||||
ac1 = acfactory.get_unconfigured_account()
|
||||
@@ -52,24 +52,24 @@ class TestOfflineAccountBasic:
|
||||
|
||||
def test_set_config_int_conversion(self, acfactory):
|
||||
ac1 = acfactory.get_unconfigured_account()
|
||||
ac1.set_config("mvbox_move", False)
|
||||
assert ac1.get_config("mvbox_move") == "0"
|
||||
ac1.set_config("mvbox_move", True)
|
||||
assert ac1.get_config("mvbox_move") == "1"
|
||||
ac1.set_config("mvbox_move", 0)
|
||||
assert ac1.get_config("mvbox_move") == "0"
|
||||
ac1.set_config("mvbox_move", 1)
|
||||
assert ac1.get_config("mvbox_move") == "1"
|
||||
ac1.set_config("bcc_self", False)
|
||||
assert ac1.get_config("bcc_self") == "0"
|
||||
ac1.set_config("bcc_self", True)
|
||||
assert ac1.get_config("bcc_self") == "1"
|
||||
ac1.set_config("bcc_self", 0)
|
||||
assert ac1.get_config("bcc_self") == "0"
|
||||
ac1.set_config("bcc_self", 1)
|
||||
assert ac1.get_config("bcc_self") == "1"
|
||||
|
||||
def test_update_config(self, acfactory):
|
||||
ac1 = acfactory.get_unconfigured_account()
|
||||
ac1.update_config({"mvbox_move": False})
|
||||
assert ac1.get_config("mvbox_move") == "0"
|
||||
ac1.update_config({"bcc_self": True})
|
||||
assert ac1.get_config("bcc_self") == "1"
|
||||
|
||||
def test_has_bccself(self, acfactory):
|
||||
ac1 = acfactory.get_unconfigured_account()
|
||||
assert "bcc_self" in ac1.get_config("sys.config_keys").split()
|
||||
assert ac1.get_config("bcc_self") == "1"
|
||||
assert ac1.get_config("bcc_self") == "0"
|
||||
|
||||
def test_selfcontact_if_unconfigured(self, acfactory):
|
||||
ac1 = acfactory.get_unconfigured_account()
|
||||
@@ -258,9 +258,6 @@ class TestOfflineChat:
|
||||
with pytest.raises(ValueError):
|
||||
ac1.set_stock_translation(dc.const.DC_STR_FILE, "xyz %1$s")
|
||||
ac1._evtracker.get_matching("DC_EVENT_WARNING")
|
||||
with pytest.raises(ValueError):
|
||||
ac1.set_stock_translation(dc.const.DC_STR_CONTACT_NOT_VERIFIED, "xyz %2$s")
|
||||
ac1._evtracker.get_matching("DC_EVENT_WARNING")
|
||||
with pytest.raises(ValueError):
|
||||
ac1.set_stock_translation(500, "xyz %1$s")
|
||||
ac1._evtracker.get_matching("DC_EVENT_WARNING")
|
||||
@@ -561,6 +558,12 @@ class TestOfflineChat:
|
||||
assert messages[0 + E2EE_INFO_MSGS].text == "msg1"
|
||||
assert os.path.exists(messages[1 + E2EE_INFO_MSGS].filename)
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason="We didn't find a way to correctly reset an account after a failed import attempt "
|
||||
"while simultaneously making sure "
|
||||
"that the password of an encrypted account survives a failed import attempt. "
|
||||
"Since passphrases are not really supported anymore, we decided to just disable the test.",
|
||||
)
|
||||
def test_import_encrypted_bak_into_encrypted_acct(self, acfactory, tmp_path):
|
||||
"""
|
||||
Test that account passphrase isn't lost if backup failed to be imported.
|
||||
|
||||
@@ -111,7 +111,7 @@ def test_dc_close_events(acfactory):
|
||||
register_global_plugin(ShutdownPlugin())
|
||||
assert hasattr(ac1, "_dc_context")
|
||||
ac1.shutdown()
|
||||
shutdowns.get(timeout=2)
|
||||
shutdowns.get()
|
||||
|
||||
|
||||
def test_wrong_db(tmp_path):
|
||||
@@ -221,7 +221,7 @@ def test_logged_ac_process_ffi_failure(acfactory):
|
||||
|
||||
# cause any event eg contact added/changed
|
||||
ac1.create_contact("something@example.org")
|
||||
res = cap.get(timeout=10)
|
||||
res = cap.get()
|
||||
assert "ac_process_ffi_event" in res
|
||||
assert "ZeroDivisionError" in res
|
||||
assert "Traceback" in res
|
||||
|
||||
@@ -46,7 +46,7 @@ deps =
|
||||
commands =
|
||||
ruff format --diff setup.py src/deltachat examples/ tests/
|
||||
ruff check src/deltachat tests/ examples/
|
||||
rst-lint --encoding 'utf-8' README.rst
|
||||
rst-lint README.rst
|
||||
|
||||
[testenv:mypy]
|
||||
deps =
|
||||
|
||||
@@ -1 +1 @@
|
||||
2025-11-16
|
||||
2026-03-14
|
||||
@@ -26,10 +26,10 @@ and an own build machine.
|
||||
i.e. `deltachat-rpc-client` and `deltachat-rpc-server`.
|
||||
|
||||
- `remote_tests_python.sh` rsyncs to a build machine and runs
|
||||
`run-python-test.sh` remotely on the build machine.
|
||||
JSON-RPC Python tests remotely on the build machine.
|
||||
|
||||
- `remote_tests_rust.sh` rsyncs to the build machine and runs
|
||||
`run-rust-test.sh` remotely on the build machine.
|
||||
Rust tests remotely on the build machine.
|
||||
|
||||
- `run-doxygen.sh` generates C-docs which are then uploaded to https://c.delta.chat/
|
||||
|
||||
|
||||
@@ -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.91.0
|
||||
RUST_VERSION=1.94.0
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
test -f "/lib/libc.musl-$ARCH.so.1" && LIBC=musl || LIBC=gnu
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# Update package cache without changing the lockfile.
|
||||
cargo update --dry-run
|
||||
|
||||
cargo deny --workspace --all-features check -D warnings
|
||||
cargo deny --workspace --all-features --locked check -D warnings
|
||||
|
||||
@@ -1,45 +1,32 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BUILD_ID=${1:?specify build ID}
|
||||
|
||||
SSHTARGET=${SSHTARGET-ci@b1.delta.chat}
|
||||
BUILDDIR=ci_builds/$BUILD_ID
|
||||
set -x
|
||||
if ! test -v SSHTARGET; then
|
||||
echo >&2 SSHTARGET is not set
|
||||
exit 1
|
||||
fi
|
||||
BUILDDIR=ci_builds/chatmailcore
|
||||
|
||||
echo "--- Copying files to $SSHTARGET:$BUILDDIR"
|
||||
|
||||
set -xe
|
||||
|
||||
ssh -oBatchMode=yes -oStrictHostKeyChecking=no $SSHTARGET mkdir -p "$BUILDDIR"
|
||||
git ls-files >.rsynclist
|
||||
# we seem to need .git for setuptools_scm versioning
|
||||
find .git >>.rsynclist
|
||||
rsync --delete --files-from=.rsynclist -az ./ "$SSHTARGET:$BUILDDIR"
|
||||
|
||||
set +x
|
||||
rsync -az --delete --mkpath --files-from=<(git ls-files) ./ "$SSHTARGET:$BUILDDIR"
|
||||
|
||||
echo "--- Running Python tests remotely"
|
||||
|
||||
ssh $SSHTARGET <<_HERE
|
||||
ssh -oBatchMode=yes -- "$SSHTARGET" <<_HERE
|
||||
set +x -e
|
||||
|
||||
# make sure all processes exit when ssh dies
|
||||
shopt -s huponexit
|
||||
|
||||
export RUSTC_WRAPPER=\`which sccache\`
|
||||
export RUSTC_WRAPPER=\`command -v sccache\`
|
||||
cd $BUILDDIR
|
||||
export TARGET=release
|
||||
export CHATMAIL_DOMAIN=$CHATMAIL_DOMAIN
|
||||
|
||||
#we rely on tox/virtualenv being available in the host
|
||||
#rm -rf virtualenv venv
|
||||
#virtualenv -q -p python3.7 venv
|
||||
#source venv/bin/activate
|
||||
#pip install -q tox virtualenv
|
||||
scripts/make-rpc-testenv.sh
|
||||
. venv/bin/activate
|
||||
|
||||
set -x
|
||||
which python
|
||||
source \$HOME/venv/bin/activate
|
||||
which python
|
||||
|
||||
bash scripts/run-python-test.sh
|
||||
cd deltachat-rpc-client
|
||||
pytest -n6 $@
|
||||
_HERE
|
||||
|
||||
@@ -1,29 +1,25 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BUILD_ID=${1:?specify build ID}
|
||||
|
||||
SSHTARGET=${SSHTARGET-ci@b1.delta.chat}
|
||||
BUILDDIR=ci_builds/$BUILD_ID
|
||||
|
||||
set -e
|
||||
if ! test -v SSHTARGET; then
|
||||
echo >&2 SSHTARGET is not set
|
||||
exit 1
|
||||
fi
|
||||
BUILDDIR=ci_builds/chatmailcore
|
||||
|
||||
echo "--- Copying files to $SSHTARGET:$BUILDDIR"
|
||||
|
||||
ssh -oBatchMode=yes -oStrictHostKeyChecking=no $SSHTARGET mkdir -p "$BUILDDIR"
|
||||
git ls-files >.rsynclist
|
||||
rsync --delete --files-from=.rsynclist -az ./ "$SSHTARGET:$BUILDDIR"
|
||||
rsync -az --delete --mkpath --files-from=<(git ls-files) ./ "$SSHTARGET:$BUILDDIR"
|
||||
|
||||
echo "--- Running Rust tests remotely"
|
||||
|
||||
ssh $SSHTARGET <<_HERE
|
||||
ssh -oBatchMode=yes -- "$SSHTARGET" <<_HERE
|
||||
set +x -e
|
||||
# make sure all processes exit when ssh dies
|
||||
shopt -s huponexit
|
||||
export RUSTC_WRAPPER=\`which sccache\`
|
||||
export RUSTC_WRAPPER=\`command -v sccache\`
|
||||
cd $BUILDDIR
|
||||
export TARGET=x86_64-unknown-linux-gnu
|
||||
export RUSTC_WRAPPER=sccache
|
||||
|
||||
bash scripts/run-rust-test.sh
|
||||
cargo nextest run
|
||||
_HERE
|
||||
|
||||
|
||||
@@ -31,6 +31,6 @@ unset CHATMAIL_DOMAIN
|
||||
|
||||
# Try to build wheels for a range of interpreters, but don't fail if they are not available.
|
||||
# E.g. musllinux_1_1 does not have PyPy interpreters as of 2022-07-10
|
||||
tox --workdir "$TOXWORKDIR" -e py38,py39,py310,py311,py312,py313,pypy38,pypy39,pypy310 --skip-missing-interpreters true
|
||||
tox --workdir "$TOXWORKDIR" -e py310,py311,py312,py313,pypy310 --skip-missing-interpreters true
|
||||
|
||||
auditwheel repair "$TOXWORKDIR"/wheelhouse/deltachat* -w "$TOXWORKDIR/wheelhouse"
|
||||
|
||||
@@ -100,7 +100,7 @@ def main():
|
||||
|
||||
today = datetime.date.today().isoformat()
|
||||
|
||||
if "alpha" not in newversion:
|
||||
if not newversion.endswith("-dev"):
|
||||
found = False
|
||||
for line in Path("CHANGELOG.md").open():
|
||||
if line == f"## [{newversion}] - {today}\n":
|
||||
|
||||
@@ -6,11 +6,11 @@ set -euo pipefail
|
||||
export TZ=UTC
|
||||
|
||||
# Provider database revision.
|
||||
REV=d041136c19a48b493823b46d472f12b9ee94ae80
|
||||
REV=996c4bc82be5a7404f70b185ff062da33bfa98d9
|
||||
|
||||
CORE_ROOT="$PWD"
|
||||
TMP="$(mktemp -d)"
|
||||
git clone --filter=blob:none https://github.com/deltachat/provider-db.git "$TMP"
|
||||
git clone --filter=blob:none https://github.com/chatmail/provider-db.git "$TMP"
|
||||
cd "$TMP"
|
||||
git checkout "$REV"
|
||||
DATE=$(git show -s --format=%cs)
|
||||
|
||||
@@ -57,11 +57,20 @@ pub struct Accounts {
|
||||
impl Accounts {
|
||||
/// Loads or creates an accounts folder at the given `dir`.
|
||||
pub async fn new(dir: PathBuf, writable: bool) -> Result<Self> {
|
||||
if writable && !dir.exists() {
|
||||
Accounts::create(&dir).await?;
|
||||
if writable {
|
||||
Self::ensure_accounts_dir(&dir).await?;
|
||||
}
|
||||
let events = Events::new();
|
||||
Accounts::open(events, dir, writable).await
|
||||
}
|
||||
|
||||
Accounts::open(dir, writable).await
|
||||
/// Loads or creates an accounts folder at the given `dir`.
|
||||
/// Uses an existing events channel.
|
||||
pub async fn new_with_events(dir: PathBuf, writable: bool, events: Events) -> Result<Self> {
|
||||
if writable {
|
||||
Self::ensure_accounts_dir(&dir).await?;
|
||||
}
|
||||
Accounts::open(events, dir, writable).await
|
||||
}
|
||||
|
||||
/// Get the ID used to log events.
|
||||
@@ -72,27 +81,33 @@ impl Accounts {
|
||||
0
|
||||
}
|
||||
|
||||
/// Creates a new default structure.
|
||||
async fn create(dir: &Path) -> Result<()> {
|
||||
fs::create_dir_all(dir)
|
||||
.await
|
||||
.context("failed to create folder")?;
|
||||
|
||||
Config::new(dir).await?;
|
||||
|
||||
/// Ensures the accounts directory and config file exist.
|
||||
/// Creates them if the directory doesn't exist, or if it exists but is empty.
|
||||
/// Errors if the directory exists with files but no config.
|
||||
async fn ensure_accounts_dir(dir: &Path) -> Result<()> {
|
||||
if !dir.exists() {
|
||||
fs::create_dir_all(dir)
|
||||
.await
|
||||
.context("Failed to create folder")?;
|
||||
Config::new(dir).await?;
|
||||
} else if !dir.join(CONFIG_NAME).exists() {
|
||||
let mut rd = fs::read_dir(dir).await?;
|
||||
ensure!(rd.next_entry().await?.is_none(), "{dir:?} is not empty");
|
||||
Config::new(dir).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens an existing accounts structure. Will error if the folder doesn't exist,
|
||||
/// no account exists and no config exists.
|
||||
async fn open(dir: PathBuf, writable: bool) -> Result<Self> {
|
||||
async fn open(events: Events, dir: PathBuf, writable: bool) -> Result<Self> {
|
||||
ensure!(dir.exists(), "directory does not exist");
|
||||
|
||||
let config_file = dir.join(CONFIG_NAME);
|
||||
ensure!(config_file.exists(), "{config_file:?} does not exist");
|
||||
|
||||
let config = Config::from_file(config_file, writable).await?;
|
||||
let events = Events::new();
|
||||
|
||||
let stockstrings = StockStrings::new();
|
||||
let push_subscriber = PushSubscriber::new();
|
||||
let accounts = config
|
||||
@@ -377,6 +392,11 @@ impl Accounts {
|
||||
"Starting background fetch for {n_accounts} accounts."
|
||||
)),
|
||||
});
|
||||
::tracing::event!(
|
||||
::tracing::Level::INFO,
|
||||
account_id = 0,
|
||||
"Starting background fetch for {n_accounts} accounts."
|
||||
);
|
||||
let mut set = JoinSet::new();
|
||||
for account in accounts {
|
||||
set.spawn(async move {
|
||||
@@ -392,6 +412,11 @@ impl Accounts {
|
||||
"Finished background fetch for {n_accounts} accounts."
|
||||
)),
|
||||
});
|
||||
::tracing::event!(
|
||||
::tracing::Level::INFO,
|
||||
account_id = 0,
|
||||
"Finished background fetch for {n_accounts} accounts."
|
||||
);
|
||||
}
|
||||
|
||||
/// Auxiliary function for [Accounts::background_fetch].
|
||||
@@ -429,6 +454,11 @@ impl Accounts {
|
||||
id: 0,
|
||||
typ: EventType::Warning("Background fetch timed out.".to_string()),
|
||||
});
|
||||
::tracing::event!(
|
||||
::tracing::Level::WARN,
|
||||
account_id = 0,
|
||||
"Background fetch timed out."
|
||||
);
|
||||
}
|
||||
events.emit(Event {
|
||||
id: 0,
|
||||
@@ -561,6 +591,7 @@ impl Config {
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn create_lock_task(dir: PathBuf) -> Result<Option<JoinHandle<anyhow::Result<()>>>> {
|
||||
let lockfile = dir.join(LOCKFILE_NAME);
|
||||
let mut lock = fd_lock::RwLock::new(fs::File::create(lockfile).await?);
|
||||
@@ -678,13 +709,12 @@ impl Config {
|
||||
// Convert them to relative paths.
|
||||
let mut modified = false;
|
||||
for account in &mut config.inner.accounts {
|
||||
if account.dir.is_absolute() {
|
||||
if let Some(old_path_parent) = account.dir.parent() {
|
||||
if let Ok(new_path) = account.dir.strip_prefix(old_path_parent) {
|
||||
account.dir = new_path.to_path_buf();
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
if account.dir.is_absolute()
|
||||
&& let Some(old_path_parent) = account.dir.parent()
|
||||
&& let Ok(new_path) = account.dir.strip_prefix(old_path_parent)
|
||||
{
|
||||
account.dir = new_path.to_path_buf();
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
if modified && writable {
|
||||
@@ -728,6 +758,7 @@ impl Config {
|
||||
}
|
||||
|
||||
/// Creates a new account in the account manager directory.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn new_account(&mut self) -> Result<AccountConfig> {
|
||||
let id = {
|
||||
let id = self.inner.next_id;
|
||||
@@ -817,6 +848,7 @@ impl Config {
|
||||
///
|
||||
/// Without this workaround removing account may fail on Windows with an error
|
||||
/// "The process cannot access the file because it is being used by another process. (os error 32)".
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn try_many_times<F, Fut, T>(f: F) -> std::result::Result<(), T>
|
||||
where
|
||||
F: Fn() -> Fut,
|
||||
@@ -888,6 +920,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_account_new_empty_existing_dir() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p: PathBuf = dir.path().join("accounts");
|
||||
|
||||
// A non-empty directory without accounts.toml should fail.
|
||||
fs::create_dir_all(&p).await.unwrap();
|
||||
fs::write(p.join("stray_file.txt"), b"hello").await.unwrap();
|
||||
assert!(Accounts::new(p.clone(), true).await.is_err());
|
||||
|
||||
// Clean up to an empty directory.
|
||||
fs::remove_file(p.join("stray_file.txt")).await.unwrap();
|
||||
|
||||
// An empty directory without accounts.toml should succeed.
|
||||
let mut accounts = Accounts::new(p.clone(), true).await.unwrap();
|
||||
assert_eq!(accounts.accounts.len(), 0);
|
||||
let id = accounts.add_account().await.unwrap();
|
||||
assert_eq!(id, 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_account_new_open_conflict() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -47,11 +47,11 @@ pub struct Aheader {
|
||||
pub public_key: SignedPublicKey,
|
||||
pub prefer_encrypt: EncryptPreference,
|
||||
|
||||
// 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.
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ impl fmt::Display for Aheader {
|
||||
let keydata = self.public_key.to_base64().chars().enumerate().fold(
|
||||
String::new(),
|
||||
|mut res, (i, c)| {
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
if i % 78 == 78 - "keydata=".len() {
|
||||
res.push(' ')
|
||||
}
|
||||
@@ -107,13 +108,11 @@ impl FromStr for Aheader {
|
||||
.remove("keydata")
|
||||
.context("keydata attribute is not found")
|
||||
.and_then(|raw| {
|
||||
SignedPublicKey::from_base64(&raw).context("autocrypt key cannot be decoded")
|
||||
})
|
||||
.and_then(|key| {
|
||||
key.verify()
|
||||
.and(Ok(key))
|
||||
.context("autocrypt key cannot be verified")
|
||||
SignedPublicKey::from_base64(&raw).context("Autocrypt key cannot be decoded")
|
||||
})?;
|
||||
public_key
|
||||
.verify_bindings()
|
||||
.context("Autocrypt key cannot be verified")?;
|
||||
|
||||
let prefer_encrypt = attributes
|
||||
.remove("prefer-encrypt")
|
||||
|
||||
49
src/blob.rs
49
src/blob.rs
@@ -1,6 +1,6 @@
|
||||
//! # Blob directory management.
|
||||
|
||||
use core::cmp::max;
|
||||
use std::cmp::max;
|
||||
use std::io::{Cursor, Seek};
|
||||
use std::iter::FusedIterator;
|
||||
use std::mem;
|
||||
@@ -256,7 +256,7 @@ impl<'a> BlobObject<'a> {
|
||||
|
||||
/// Recode image to avatar size.
|
||||
pub async fn recode_to_avatar_size(&mut self, context: &Context) -> Result<()> {
|
||||
let (img_wh, max_bytes) =
|
||||
let (max_wh, max_bytes) =
|
||||
match MediaQuality::from_i32(context.get_config_int(Config::MediaQuality).await?)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
@@ -273,7 +273,7 @@ impl<'a> BlobObject<'a> {
|
||||
let is_avatar = true;
|
||||
self.check_or_recode_to_size(
|
||||
context, None, // The name of an avatar doesn't matter
|
||||
viewtype, img_wh, max_bytes, is_avatar,
|
||||
viewtype, max_wh, max_bytes, is_avatar,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
@@ -294,7 +294,7 @@ impl<'a> BlobObject<'a> {
|
||||
name: Option<String>,
|
||||
viewtype: &mut Viewtype,
|
||||
) -> Result<String> {
|
||||
let (img_wh, max_bytes) =
|
||||
let (max_wh, max_bytes) =
|
||||
match MediaQuality::from_i32(context.get_config_int(Config::MediaQuality).await?)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
@@ -305,13 +305,15 @@ impl<'a> BlobObject<'a> {
|
||||
MediaQuality::Worse => (constants::WORSE_IMAGE_SIZE, constants::WORSE_IMAGE_BYTES),
|
||||
};
|
||||
let is_avatar = false;
|
||||
self.check_or_recode_to_size(context, name, viewtype, img_wh, max_bytes, is_avatar)
|
||||
self.check_or_recode_to_size(context, name, viewtype, max_wh, max_bytes, is_avatar)
|
||||
}
|
||||
|
||||
/// Checks or recodes the image so that it fits into limits on width/height and byte size.
|
||||
/// Checks or recodes the image so that it fits into limits on width/height and/or byte size.
|
||||
///
|
||||
/// If `!is_avatar`, then if `max_bytes` is exceeded, reduces the image to `img_wh` and proceeds
|
||||
/// with the result without rechecking.
|
||||
/// If `!is_avatar`, then if `max_bytes` is exceeded, reduces the image to `max_wh` and proceeds
|
||||
/// with the result (even if `max_bytes` is still exceeded).
|
||||
///
|
||||
/// If `is_avatar`, the resolution will be reduced in a loop until the image fits `max_bytes`.
|
||||
///
|
||||
/// This modifies the blob object in-place.
|
||||
///
|
||||
@@ -319,12 +321,13 @@ impl<'a> BlobObject<'a> {
|
||||
/// then the updated user-visible filename will be returned;
|
||||
/// this may be necessary because the format may be changed to JPG,
|
||||
/// i.e. "image.png" -> "image.jpg".
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn check_or_recode_to_size(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
name: Option<String>,
|
||||
viewtype: &mut Viewtype,
|
||||
mut img_wh: u32,
|
||||
max_wh: u32,
|
||||
max_bytes: usize,
|
||||
is_avatar: bool,
|
||||
) -> Result<String> {
|
||||
@@ -386,7 +389,14 @@ impl<'a> BlobObject<'a> {
|
||||
_ => img,
|
||||
};
|
||||
|
||||
let exceeds_wh = img.width() > img_wh || img.height() > img_wh;
|
||||
// max_wh is the maximum image width and height, i.e. the resolution-limit.
|
||||
// target_wh target-resolution for resizing the image.
|
||||
let exceeds_wh = img.width() > max_wh || img.height() > max_wh;
|
||||
let mut target_wh = if exceeds_wh {
|
||||
max_wh
|
||||
} else {
|
||||
max(img.width(), img.height())
|
||||
};
|
||||
let exceeds_max_bytes = nr_bytes > max_bytes as u64;
|
||||
|
||||
let jpeg_quality = 75;
|
||||
@@ -425,15 +435,6 @@ impl<'a> BlobObject<'a> {
|
||||
});
|
||||
|
||||
if do_scale {
|
||||
if !exceeds_wh {
|
||||
img_wh = max(img.width(), img.height());
|
||||
// PNGs and WebPs may be huge because of animation, which is lost by the `image`
|
||||
// crate when recoding, so don't scale them down.
|
||||
if matches!(fmt, ImageFormat::Jpeg) || !encoded.is_empty() {
|
||||
img_wh = img_wh * 2 / 3;
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
if mem::take(&mut add_white_bg) {
|
||||
self::add_white_bg(&mut img);
|
||||
@@ -448,9 +449,9 @@ impl<'a> BlobObject<'a> {
|
||||
// usually has less pixels by cropping, UI that needs to wait anyways,
|
||||
// and also benefits from slightly better (5%) encoding of Triangle-filtered images.
|
||||
let new_img = if is_avatar {
|
||||
img.resize(img_wh, img_wh, image::imageops::FilterType::Triangle)
|
||||
img.resize(target_wh, target_wh, image::imageops::FilterType::Triangle)
|
||||
} else {
|
||||
img.thumbnail(img_wh, img_wh)
|
||||
img.thumbnail(target_wh, target_wh)
|
||||
};
|
||||
|
||||
if encoded_img_exceeds_bytes(
|
||||
@@ -461,19 +462,19 @@ impl<'a> BlobObject<'a> {
|
||||
&mut encoded,
|
||||
)? && is_avatar
|
||||
{
|
||||
if img_wh < 20 {
|
||||
if target_wh < 20 {
|
||||
return Err(format_err!(
|
||||
"Failed to scale image to below {max_bytes}B.",
|
||||
));
|
||||
}
|
||||
|
||||
img_wh = img_wh * 2 / 3;
|
||||
target_wh = target_wh * 2 / 3;
|
||||
} else {
|
||||
info!(
|
||||
context,
|
||||
"Final scaled-down image size: {}B ({}px).",
|
||||
encoded.len(),
|
||||
img_wh
|
||||
target_wh
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -173,11 +173,8 @@ async fn test_selfavatar_outside_blobdir() {
|
||||
.unwrap();
|
||||
let avatar_blob = t.get_config(Config::Selfavatar).await.unwrap().unwrap();
|
||||
let avatar_path = Path::new(&avatar_blob);
|
||||
assert!(
|
||||
avatar_blob.ends_with("7dde69e06b5ae6c27520a436bbfd65b.jpg"),
|
||||
"The avatar filename should be its hash, put instead it's {avatar_blob}"
|
||||
);
|
||||
let scaled_avatar_size = file_size(avatar_path).await;
|
||||
info!(&t, "Scaled avatar size: {scaled_avatar_size}.");
|
||||
assert!(scaled_avatar_size < avatar_bytes.len() as u64);
|
||||
|
||||
check_image_size(avatar_src, 1000, 1000);
|
||||
@@ -187,6 +184,11 @@ async fn test_selfavatar_outside_blobdir() {
|
||||
constants::BALANCED_AVATAR_SIZE,
|
||||
);
|
||||
|
||||
assert!(
|
||||
avatar_blob.ends_with("2a048b6fcd86448032b854ea1ad7608.jpg"),
|
||||
"The avatar filename should be its hash, but instead it's {avatar_blob}"
|
||||
);
|
||||
|
||||
let mut blob = BlobObject::create_and_deduplicate(&t, avatar_path, avatar_path).unwrap();
|
||||
let viewtype = &mut Viewtype::Image;
|
||||
let strict_limits = true;
|
||||
@@ -796,3 +798,56 @@ async fn test_create_and_deduplicate_from_bytes() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that an image that already fits into the width limit,
|
||||
/// but not the bytes limit,
|
||||
/// is compressed without changing the resolution.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_recode_without_downscaling() -> Result<()> {
|
||||
let t = &TestContext::new().await;
|
||||
|
||||
let image = include_bytes!("../../test-data/image/screenshot120x120.jpg");
|
||||
const { assert!(120 < constants::WORSE_AVATAR_SIZE) };
|
||||
|
||||
for is_avatar in [true, false] {
|
||||
let mut blob =
|
||||
BlobObject::create_and_deduplicate_from_bytes(t, image, "image.jpg").unwrap();
|
||||
let image_path = blob.to_abs_path();
|
||||
check_image_size(&image_path, 120, 120);
|
||||
|
||||
assert!(
|
||||
fs::metadata(&image_path).await.unwrap().len() > constants::WORSE_AVATAR_BYTES as u64
|
||||
);
|
||||
|
||||
// Repeat the check, because a second call to `check_or_recode_to_size()`
|
||||
// is not supposed to change anything:
|
||||
let mut imgs = vec![];
|
||||
for _ in 0..2 {
|
||||
let mut viewtype = Viewtype::Image;
|
||||
let new_name = blob.check_or_recode_to_size(
|
||||
t,
|
||||
Some("image.jpg".to_string()),
|
||||
&mut viewtype,
|
||||
constants::WORSE_AVATAR_SIZE,
|
||||
constants::WORSE_AVATAR_BYTES,
|
||||
is_avatar,
|
||||
)?;
|
||||
let image_path = blob.to_abs_path();
|
||||
assert_eq!(new_name, "image.jpg"); // The name shall not have changed
|
||||
assert_eq!(viewtype, Viewtype::Image); // The viewtype shall not have changed
|
||||
let img = check_image_size(&image_path, 120, 120); // The resolution shall not have changed
|
||||
imgs.push(img);
|
||||
|
||||
let new_image_bytes = fs::metadata(&image_path).await.unwrap().len();
|
||||
assert!(
|
||||
new_image_bytes < constants::WORSE_AVATAR_BYTES as u64,
|
||||
"The new image size, {new_image_bytes}, should be lower than {}, is_avatar={is_avatar}",
|
||||
constants::WORSE_AVATAR_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(imgs[0], imgs[1]);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
338
src/calls.rs
338
src/calls.rs
@@ -4,21 +4,23 @@
|
||||
//! This means, the "Call ID" is a "Message ID" - similar to Webxdc IDs.
|
||||
use crate::chat::ChatIdBlocked;
|
||||
use crate::chat::{Chat, ChatId, send_msg};
|
||||
use crate::config::Config;
|
||||
use crate::constants::{Blocked, Chattype};
|
||||
use crate::contact::ContactId;
|
||||
use crate::context::Context;
|
||||
use crate::context::{Context, WeakContext};
|
||||
use crate::events::EventType;
|
||||
use crate::headerdef::HeaderDef;
|
||||
use crate::log::warn;
|
||||
use crate::message::{self, Message, MsgId, Viewtype};
|
||||
use crate::message::{Message, MsgId, Viewtype, markseen_msgs};
|
||||
use crate::mimeparser::{MimeMessage, SystemMessage};
|
||||
use crate::net::dns::lookup_host_with_cache;
|
||||
use crate::param::Param;
|
||||
use crate::tools::time;
|
||||
use crate::stock_str;
|
||||
use crate::tools::{normalize_text, time};
|
||||
use anyhow::{Context as _, Result, ensure};
|
||||
use sdp::SessionDescription;
|
||||
use deltachat_derive::{FromSql, ToSql};
|
||||
use num_traits::FromPrimitive;
|
||||
use serde::Serialize;
|
||||
use std::io::Cursor;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
use tokio::task;
|
||||
@@ -33,7 +35,7 @@ use tokio::time::sleep;
|
||||
///
|
||||
/// 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;
|
||||
const RINGING_SECONDS: i64 = 120;
|
||||
|
||||
// For persisting parameters in the call, we use Param::Arg*
|
||||
|
||||
@@ -77,6 +79,7 @@ impl CallInfo {
|
||||
}
|
||||
|
||||
fn remaining_ring_seconds(&self) -> i64 {
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
let remaining_seconds = self.msg.timestamp_sent + RINGING_SECONDS - time();
|
||||
remaining_seconds.clamp(0, RINGING_SECONDS)
|
||||
}
|
||||
@@ -86,7 +89,7 @@ impl CallInfo {
|
||||
.sql
|
||||
.execute(
|
||||
"UPDATE msgs SET txt=?, txt_normalized=? WHERE id=?",
|
||||
(text, message::normalize_text(text), self.msg.id),
|
||||
(text, normalize_text(text), self.msg.id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -101,10 +104,14 @@ impl CallInfo {
|
||||
};
|
||||
|
||||
if self.is_incoming() {
|
||||
self.update_text(context, &format!("Incoming call\n{duration}"))
|
||||
let incoming_call_str =
|
||||
stock_str::incoming_call(context, self.has_video_initially()).await;
|
||||
self.update_text(context, &format!("{incoming_call_str}\n{duration}"))
|
||||
.await?;
|
||||
} else {
|
||||
self.update_text(context, &format!("Outgoing call\n{duration}"))
|
||||
let outgoing_call_str =
|
||||
stock_str::outgoing_call(context, self.has_video_initially()).await;
|
||||
self.update_text(context, &format!("{outgoing_call_str}\n{duration}"))
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -123,6 +130,14 @@ impl CallInfo {
|
||||
self.msg.param.exists(CALL_ACCEPTED_TIMESTAMP)
|
||||
}
|
||||
|
||||
/// Returns true if the call is started as a video call.
|
||||
pub fn has_video_initially(&self) -> bool {
|
||||
self.msg
|
||||
.param
|
||||
.get_bool(Param::WebrtcHasVideoInitially)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Returns true if the call is missed
|
||||
/// because the caller canceled it
|
||||
/// explicitly before ringing stopped.
|
||||
@@ -161,6 +176,7 @@ impl CallInfo {
|
||||
}
|
||||
|
||||
/// Returns call duration in seconds.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub fn duration_seconds(&self) -> i64 {
|
||||
if let (Some(start), Some(end)) = (
|
||||
self.msg.param.get_i64(CALL_ACCEPTED_TIMESTAMP),
|
||||
@@ -182,6 +198,7 @@ impl Context {
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
place_call_info: String,
|
||||
has_video_initially: bool,
|
||||
) -> Result<MsgId> {
|
||||
let chat = Chat::load_from_db(self, chat_id).await?;
|
||||
ensure!(
|
||||
@@ -190,17 +207,21 @@ impl Context {
|
||||
);
|
||||
ensure!(!chat.is_self_talk(), "Cannot call self");
|
||||
|
||||
let outgoing_call_str = stock_str::outgoing_call(self, has_video_initially).await;
|
||||
let mut call = Message {
|
||||
viewtype: Viewtype::Call,
|
||||
text: "Outgoing call".into(),
|
||||
text: outgoing_call_str,
|
||||
..Default::default()
|
||||
};
|
||||
call.param.set(Param::WebrtcRoom, &place_call_info);
|
||||
call.param
|
||||
.set_int(Param::WebrtcHasVideoInitially, has_video_initially.into());
|
||||
call.id = send_msg(self, chat_id, &mut call).await?;
|
||||
|
||||
let wait = RINGING_SECONDS;
|
||||
let context = self.get_weak_context();
|
||||
task::spawn(Context::emit_end_call_if_unaccepted(
|
||||
self.clone(),
|
||||
context,
|
||||
wait.try_into()?,
|
||||
call.id,
|
||||
));
|
||||
@@ -228,6 +249,7 @@ impl Context {
|
||||
if chat.is_contact_request() {
|
||||
chat.id.accept(self).await?;
|
||||
}
|
||||
markseen_msgs(self, vec![call_id]).await?;
|
||||
|
||||
// send an acceptance message around: to the caller as well as to the other devices of the callee
|
||||
let mut msg = Message {
|
||||
@@ -244,6 +266,7 @@ impl Context {
|
||||
self.emit_event(EventType::IncomingCallAccepted {
|
||||
msg_id: call.msg.id,
|
||||
chat_id: call.msg.chat_id,
|
||||
from_this_device: true,
|
||||
});
|
||||
self.emit_msgs_changed(call.msg.chat_id, call_id);
|
||||
Ok(())
|
||||
@@ -262,10 +285,13 @@ impl Context {
|
||||
if !call.is_accepted() {
|
||||
if call.is_incoming() {
|
||||
call.mark_as_ended(self).await?;
|
||||
call.update_text(self, "Declined call").await?;
|
||||
markseen_msgs(self, vec![call_id]).await?;
|
||||
let declined_call_str = stock_str::declined_call(self).await;
|
||||
call.update_text(self, &declined_call_str).await?;
|
||||
} else {
|
||||
call.mark_as_canceled(self).await?;
|
||||
call.update_text(self, "Canceled call").await?;
|
||||
let canceled_call_str = stock_str::canceled_call(self).await;
|
||||
call.update_text(self, &canceled_call_str).await?;
|
||||
}
|
||||
} else {
|
||||
call.mark_as_ended(self).await?;
|
||||
@@ -291,11 +317,12 @@ impl Context {
|
||||
}
|
||||
|
||||
async fn emit_end_call_if_unaccepted(
|
||||
context: Context,
|
||||
context: WeakContext,
|
||||
wait: u64,
|
||||
call_id: MsgId,
|
||||
) -> Result<()> {
|
||||
sleep(Duration::from_secs(wait)).await;
|
||||
let context = context.upgrade()?;
|
||||
let Some(mut call) = context.load_call_by_id(call_id).await? else {
|
||||
warn!(
|
||||
context,
|
||||
@@ -306,10 +333,12 @@ impl Context {
|
||||
if !call.is_accepted() && !call.is_ended() {
|
||||
if call.is_incoming() {
|
||||
call.mark_as_canceled(&context).await?;
|
||||
call.update_text(&context, "Missed call").await?;
|
||||
let missed_call_str = stock_str::missed_call(&context).await;
|
||||
call.update_text(&context, &missed_call_str).await?;
|
||||
} else {
|
||||
call.mark_as_ended(&context).await?;
|
||||
call.update_text(&context, "Canceled call").await?;
|
||||
let canceled_call_str = stock_str::canceled_call(&context).await;
|
||||
call.update_text(&context, &canceled_call_str).await?;
|
||||
}
|
||||
context.emit_msgs_changed(call.msg.chat_id, call_id);
|
||||
context.emit_event(EventType::CallEnded {
|
||||
@@ -334,48 +363,55 @@ impl Context {
|
||||
|
||||
if call.is_incoming() {
|
||||
if call.is_stale() {
|
||||
call.update_text(self, "Missed call").await?;
|
||||
let missed_call_str = stock_str::missed_call(self).await;
|
||||
call.update_text(self, &missed_call_str).await?;
|
||||
self.emit_incoming_msg(call.msg.chat_id, call_id); // notify missed call
|
||||
} else {
|
||||
call.update_text(self, "Incoming call").await?;
|
||||
let incoming_call_str =
|
||||
stock_str::incoming_call(self, call.has_video_initially()).await;
|
||||
call.update_text(self, &incoming_call_str).await?;
|
||||
self.emit_msgs_changed(call.msg.chat_id, call_id); // ringing calls are not additionally notified
|
||||
let has_video = match sdp_has_video(&call.place_call_info) {
|
||||
Ok(has_video) => has_video,
|
||||
Err(err) => {
|
||||
warn!(self, "Failed to determine if SDP offer has video: {err:#}.");
|
||||
false
|
||||
}
|
||||
let can_call_me = match who_can_call_me(self).await? {
|
||||
WhoCanCallMe::Contacts => ChatIdBlocked::lookup_by_contact(self, from_id)
|
||||
.await?
|
||||
.is_some_and(|chat_id_blocked| {
|
||||
match chat_id_blocked.blocked {
|
||||
Blocked::Not => true,
|
||||
Blocked::Yes | Blocked::Request => {
|
||||
// Do not notify about incoming calls
|
||||
// from contact requests and blocked contacts.
|
||||
//
|
||||
// User can still access the call and accept it
|
||||
// via the chat in case of contact requests.
|
||||
false
|
||||
}
|
||||
}
|
||||
}),
|
||||
WhoCanCallMe::Everybody => ChatIdBlocked::lookup_by_contact(self, from_id)
|
||||
.await?
|
||||
.is_none_or(|chat_id_blocked| chat_id_blocked.blocked != Blocked::Yes),
|
||||
WhoCanCallMe::Nobody => false,
|
||||
};
|
||||
if let Some(chat_id_blocked) =
|
||||
ChatIdBlocked::lookup_by_contact(self, from_id).await?
|
||||
{
|
||||
match chat_id_blocked.blocked {
|
||||
Blocked::Not => {
|
||||
self.emit_event(EventType::IncomingCall {
|
||||
msg_id: call.msg.id,
|
||||
chat_id: call.msg.chat_id,
|
||||
place_call_info: call.place_call_info.to_string(),
|
||||
has_video,
|
||||
});
|
||||
}
|
||||
Blocked::Yes | Blocked::Request => {
|
||||
// Do not notify about incoming calls
|
||||
// from contact requests and blocked contacts.
|
||||
//
|
||||
// User can still access the call and accept it
|
||||
// via the chat in case of contact requests.
|
||||
}
|
||||
}
|
||||
if can_call_me {
|
||||
self.emit_event(EventType::IncomingCall {
|
||||
msg_id: call.msg.id,
|
||||
chat_id: call.msg.chat_id,
|
||||
place_call_info: call.place_call_info.to_string(),
|
||||
has_video: call.has_video_initially(),
|
||||
});
|
||||
}
|
||||
let wait = call.remaining_ring_seconds();
|
||||
let context = self.get_weak_context();
|
||||
task::spawn(Context::emit_end_call_if_unaccepted(
|
||||
self.clone(),
|
||||
context,
|
||||
wait.try_into()?,
|
||||
call.msg.id,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
call.update_text(self, "Outgoing call").await?;
|
||||
let outgoing_call_str =
|
||||
stock_str::outgoing_call(self, call.has_video_initially()).await;
|
||||
call.update_text(self, &outgoing_call_str).await?;
|
||||
self.emit_msgs_changed(call.msg.chat_id, call_id);
|
||||
}
|
||||
} else {
|
||||
@@ -397,6 +433,7 @@ impl Context {
|
||||
self.emit_event(EventType::IncomingCallAccepted {
|
||||
msg_id: call.msg.id,
|
||||
chat_id: call.msg.chat_id,
|
||||
from_this_device: false,
|
||||
});
|
||||
} else {
|
||||
let accept_call_info = mime_message
|
||||
@@ -425,19 +462,23 @@ impl Context {
|
||||
if call.is_incoming() {
|
||||
if from_id == ContactId::SELF {
|
||||
call.mark_as_ended(self).await?;
|
||||
call.update_text(self, "Declined call").await?;
|
||||
let declined_call_str = stock_str::declined_call(self).await;
|
||||
call.update_text(self, &declined_call_str).await?;
|
||||
} else {
|
||||
call.mark_as_canceled(self).await?;
|
||||
call.update_text(self, "Missed call").await?;
|
||||
let missed_call_str = stock_str::missed_call(self).await;
|
||||
call.update_text(self, &missed_call_str).await?;
|
||||
}
|
||||
} else {
|
||||
// outgoing
|
||||
if from_id == ContactId::SELF {
|
||||
call.mark_as_canceled(self).await?;
|
||||
call.update_text(self, "Canceled call").await?;
|
||||
let canceled_call_str = stock_str::canceled_call(self).await;
|
||||
call.update_text(self, &canceled_call_str).await?;
|
||||
} else {
|
||||
call.mark_as_ended(self).await?;
|
||||
call.update_text(self, "Declined call").await?;
|
||||
let declined_call_str = stock_str::declined_call(self).await;
|
||||
call.update_text(self, &declined_call_str).await?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -493,19 +534,6 @@ impl Context {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if SDP offer has a video.
|
||||
pub fn sdp_has_video(sdp: &str) -> Result<bool> {
|
||||
let mut cursor = Cursor::new(sdp);
|
||||
let session_description =
|
||||
SessionDescription::unmarshal(&mut cursor).context("Failed to parse SDP")?;
|
||||
for media_description in &session_description.media_descriptions {
|
||||
if media_description.media_name.media == "video" {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// State of the call for display in the message bubble.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum CallState {
|
||||
@@ -603,33 +631,7 @@ struct IceServer {
|
||||
pub credential: Option<String>,
|
||||
}
|
||||
|
||||
/// Creates JSON with ICE servers.
|
||||
async fn create_ice_servers(
|
||||
context: &Context,
|
||||
hostname: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<String> {
|
||||
// Do not use cache because there is no TLS.
|
||||
let load_cache = false;
|
||||
let urls: Vec<String> = lookup_host_with_cache(context, hostname, port, "", load_cache)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|addr| format!("turn:{addr}"))
|
||||
.collect();
|
||||
|
||||
let ice_server = IceServer {
|
||||
urls,
|
||||
username: Some(username.to_string()),
|
||||
credential: Some(password.to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&[ice_server])?;
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
/// Creates JSON with ICE servers from a line received over IMAP METADATA.
|
||||
/// Creates ICE servers from a line received over IMAP METADATA.
|
||||
///
|
||||
/// IMAP METADATA returns a line such as
|
||||
/// `example.com:3478:1758650868:8Dqkyyu11MVESBqjbIylmB06rv8=`
|
||||
@@ -639,20 +641,107 @@ async fn create_ice_servers(
|
||||
/// while `8Dqkyyu11MVESBqjbIylmB06rv8=`
|
||||
/// is the password.
|
||||
pub(crate) async fn create_ice_servers_from_metadata(
|
||||
context: &Context,
|
||||
metadata: &str,
|
||||
) -> Result<(i64, String)> {
|
||||
) -> Result<(i64, Vec<UnresolvedIceServer>)> {
|
||||
let (hostname, rest) = metadata.split_once(':').context("Missing hostname")?;
|
||||
let (port, rest) = rest.split_once(':').context("Missing port")?;
|
||||
let port = u16::from_str(port).context("Failed to parse the port")?;
|
||||
let (ts, password) = rest.split_once(':').context("Missing timestamp")?;
|
||||
let expiration_timestamp = i64::from_str(ts).context("Failed to parse the timestamp")?;
|
||||
let ice_servers = create_ice_servers(context, hostname, port, ts, password).await?;
|
||||
let ice_servers = vec![UnresolvedIceServer::Turn {
|
||||
hostname: hostname.to_string(),
|
||||
port,
|
||||
username: ts.to_string(),
|
||||
credential: password.to_string(),
|
||||
}];
|
||||
Ok((expiration_timestamp, ice_servers))
|
||||
}
|
||||
|
||||
/// STUN or TURN server with unresolved DNS name.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum UnresolvedIceServer {
|
||||
/// STUN server.
|
||||
Stun { hostname: String, port: u16 },
|
||||
|
||||
/// TURN server with the username and password.
|
||||
Turn {
|
||||
hostname: String,
|
||||
port: u16,
|
||||
username: String,
|
||||
credential: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Resolves domain names of ICE servers.
|
||||
///
|
||||
/// On failure to resolve, logs the error
|
||||
/// and skips the server, but does not fail.
|
||||
pub(crate) async fn resolve_ice_servers(
|
||||
context: &Context,
|
||||
unresolved_ice_servers: Vec<UnresolvedIceServer>,
|
||||
) -> Result<String> {
|
||||
let mut result: Vec<IceServer> = Vec::new();
|
||||
|
||||
// Do not use cache because there is no TLS.
|
||||
let load_cache = false;
|
||||
|
||||
for unresolved_ice_server in unresolved_ice_servers {
|
||||
match unresolved_ice_server {
|
||||
UnresolvedIceServer::Stun { hostname, port } => {
|
||||
match lookup_host_with_cache(context, &hostname, port, "", load_cache).await {
|
||||
Ok(addrs) => {
|
||||
let urls: Vec<String> = addrs
|
||||
.into_iter()
|
||||
.map(|addr| format!("stun:{addr}"))
|
||||
.collect();
|
||||
let stun_server = IceServer {
|
||||
urls,
|
||||
username: None,
|
||||
credential: None,
|
||||
};
|
||||
result.push(stun_server);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
context,
|
||||
"Failed to resolve STUN {hostname}:{port}: {err:#}."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
UnresolvedIceServer::Turn {
|
||||
hostname,
|
||||
port,
|
||||
username,
|
||||
credential,
|
||||
} => match lookup_host_with_cache(context, &hostname, port, "", load_cache).await {
|
||||
Ok(addrs) => {
|
||||
let urls: Vec<String> = addrs
|
||||
.into_iter()
|
||||
.map(|addr| format!("turn:{addr}"))
|
||||
.collect();
|
||||
let turn_server = IceServer {
|
||||
urls,
|
||||
username: Some(username),
|
||||
credential: Some(credential),
|
||||
};
|
||||
result.push(turn_server);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
context,
|
||||
"Failed to resolve TURN {hostname}:{port}: {err:#}."
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
let json = serde_json::to_string(&result)?;
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
/// Creates JSON with ICE servers when no TURN servers are known.
|
||||
pub(crate) async fn create_fallback_ice_servers(context: &Context) -> Result<String> {
|
||||
pub(crate) fn create_fallback_ice_servers() -> Vec<UnresolvedIceServer> {
|
||||
// Do not use public STUN server from https://stunprotocol.org/.
|
||||
// It changes the hostname every year
|
||||
// (e.g. stunserver2025.stunprotocol.org
|
||||
@@ -660,25 +749,18 @@ pub(crate) async fn create_fallback_ice_servers(context: &Context) -> Result<Str
|
||||
// because of bandwidth costs:
|
||||
// <https://github.com/jselbie/stunserver/issues/50>
|
||||
|
||||
// We use nine.testrun.org for a default STUN server.
|
||||
let hostname = "nine.testrun.org";
|
||||
|
||||
// Do not use cache because there is no TLS.
|
||||
let load_cache = false;
|
||||
let urls: Vec<String> = lookup_host_with_cache(context, hostname, STUN_PORT, "", load_cache)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|addr| format!("stun:{addr}"))
|
||||
.collect();
|
||||
|
||||
let ice_server = IceServer {
|
||||
urls,
|
||||
username: None,
|
||||
credential: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&[ice_server])?;
|
||||
Ok(json)
|
||||
vec![
|
||||
UnresolvedIceServer::Stun {
|
||||
hostname: "nine.testrun.org".to_string(),
|
||||
port: STUN_PORT,
|
||||
},
|
||||
UnresolvedIceServer::Turn {
|
||||
hostname: "turn.delta.chat".to_string(),
|
||||
port: STUN_PORT,
|
||||
username: "public".to_string(),
|
||||
credential: "o4tR7yG4rG2slhXqRUf9zgmHz".to_string(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Returns JSON with ICE servers.
|
||||
@@ -692,11 +774,39 @@ pub(crate) async fn create_fallback_ice_servers(context: &Context) -> Result<Str
|
||||
/// <https://github.com/deltachat/deltachat-desktop/issues/5447>.
|
||||
pub async fn ice_servers(context: &Context) -> Result<String> {
|
||||
if let Some(ref metadata) = *context.metadata.read().await {
|
||||
Ok(metadata.ice_servers.clone())
|
||||
let ice_servers = resolve_ice_servers(context, metadata.ice_servers.clone()).await?;
|
||||
Ok(ice_servers)
|
||||
} else {
|
||||
Ok("[]".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// "Who can call me" config options.
|
||||
#[derive(
|
||||
Debug, Default, Display, Clone, Copy, PartialEq, Eq, FromPrimitive, ToPrimitive, FromSql, ToSql,
|
||||
)]
|
||||
#[repr(u8)]
|
||||
pub enum WhoCanCallMe {
|
||||
/// Everybody can call me if they are not blocked.
|
||||
///
|
||||
/// This includes contact requests.
|
||||
Everybody = 0,
|
||||
|
||||
/// Every contact who is not blocked and not a contact request, can call.
|
||||
#[default]
|
||||
Contacts = 1,
|
||||
|
||||
/// Nobody can call me.
|
||||
Nobody = 2,
|
||||
}
|
||||
|
||||
/// Returns currently configuration of the "who can call me" option.
|
||||
async fn who_can_call_me(context: &Context) -> Result<WhoCanCallMe> {
|
||||
let who_can_call_me =
|
||||
WhoCanCallMe::from_i32(context.get_config_int(Config::WhoCanCallMe).await?)
|
||||
.unwrap_or_default();
|
||||
Ok(who_can_call_me)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod calls_tests;
|
||||
|
||||
@@ -2,7 +2,8 @@ use super::*;
|
||||
use crate::chat::forward_msgs;
|
||||
use crate::config::Config;
|
||||
use crate::constants::DC_CHAT_ID_TRASH;
|
||||
use crate::receive_imf::{receive_imf, receive_imf_from_inbox};
|
||||
use crate::message::MessageState;
|
||||
use crate::receive_imf::receive_imf;
|
||||
use crate::test_utils::{TestContext, TestContextManager};
|
||||
|
||||
struct CallSetup {
|
||||
@@ -25,13 +26,6 @@ async fn assert_text(t: &TestContext, call_id: MsgId, text: &str) -> Result<()>
|
||||
const PLACE_INFO: &str = "v=0\r\no=alice 2890844526 2890844526 IN IP4 host.anywhere.com\r\ns=-\r\nc=IN IP4 host.anywhere.com\r\nt=0 0\r\nm=audio 62986 RTP/AVP 0 4 18\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:4 G723/8000\r\na=rtpmap:18 G729/8000\r\na=inactive\r\n";
|
||||
const ACCEPT_INFO: &str = "v=0\r\no=bob 2890844730 2890844731 IN IP4 host.example.com\r\ns=\r\nc=IN IP4 host.example.com\r\nt=0 0\r\nm=audio 54344 RTP/AVP 0 4\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:4 G723/8000\r\na=inactive\r\n";
|
||||
|
||||
/// Example from <https://datatracker.ietf.org/doc/rfc9143/>
|
||||
/// with `s= ` replaced with `s=-`.
|
||||
///
|
||||
/// `s=` cannot be empty according to RFC 3264,
|
||||
/// so it is more clear as `s=-`.
|
||||
const PLACE_INFO_VIDEO: &str = "v=0\r\no=alice 2890844526 2890844526 IN IP6 2001:db8::3\r\ns=-\r\nc=IN IP6 2001:db8::3\r\nt=0 0\r\na=group:BUNDLE foo bar\r\n\r\nm=audio 10000 RTP/AVP 0 8 97\r\nb=AS:200\r\na=mid:foo\r\na=rtcp-mux\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:8 PCMA/8000\r\na=rtpmap:97 iLBC/8000\r\na=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r\n\r\nm=video 10002 RTP/AVP 31 32\r\nb=AS:1000\r\na=mid:bar\r\na=rtcp-mux\r\na=rtpmap:31 H261/90000\r\na=rtpmap:32 MPV/90000\r\na=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r\n";
|
||||
|
||||
async fn setup_call() -> Result<CallSetup> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = tcm.alice().await;
|
||||
@@ -52,7 +46,7 @@ async fn setup_call() -> Result<CallSetup> {
|
||||
bob2.create_chat(&alice).await;
|
||||
|
||||
let test_msg_id = alice
|
||||
.place_outgoing_call(alice_chat.id, PLACE_INFO.to_string())
|
||||
.place_outgoing_call(alice_chat.id, PLACE_INFO.to_string(), true)
|
||||
.await?;
|
||||
let sent1 = alice.pop_sent_msg().await;
|
||||
assert_eq!(sent1.sender_msg_id, test_msg_id);
|
||||
@@ -68,7 +62,8 @@ async fn setup_call() -> Result<CallSetup> {
|
||||
assert!(!info.is_incoming());
|
||||
assert!(!info.is_accepted());
|
||||
assert_eq!(info.place_call_info, PLACE_INFO);
|
||||
assert_text(t, m.id, "Outgoing call").await?;
|
||||
assert_eq!(info.has_video_initially(), true);
|
||||
assert_text(t, m.id, "Outgoing video call").await?;
|
||||
assert_eq!(call_state(t, m.id).await?, CallState::Alerting);
|
||||
}
|
||||
|
||||
@@ -89,7 +84,8 @@ async fn setup_call() -> Result<CallSetup> {
|
||||
assert!(info.is_incoming());
|
||||
assert!(!info.is_accepted());
|
||||
assert_eq!(info.place_call_info, PLACE_INFO);
|
||||
assert_text(t, m.id, "Incoming call").await?;
|
||||
assert_eq!(info.has_video_initially(), true);
|
||||
assert_text(t, m.id, "Incoming video call").await?;
|
||||
assert_eq!(call_state(t, m.id).await?, CallState::Alerting);
|
||||
}
|
||||
|
||||
@@ -120,9 +116,28 @@ async fn accept_call() -> Result<CallSetup> {
|
||||
// Bob accepts the incoming call
|
||||
bob.accept_incoming_call(bob_call.id, ACCEPT_INFO.to_string())
|
||||
.await?;
|
||||
assert_text(&bob, bob_call.id, "Incoming call").await?;
|
||||
assert_eq!(bob_call.id.get_state(&bob).await?, MessageState::InSeen);
|
||||
// Bob sends an MDN to Alice.
|
||||
assert_eq!(
|
||||
bob.sql
|
||||
.count(
|
||||
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
|
||||
(bob_call.id, bob_call.from_id)
|
||||
)
|
||||
.await?,
|
||||
1
|
||||
);
|
||||
assert_text(&bob, bob_call.id, "Incoming video call").await?;
|
||||
bob.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
|
||||
.get_matching(|evt| {
|
||||
matches!(
|
||||
evt,
|
||||
EventType::IncomingCallAccepted {
|
||||
from_this_device: true,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.await;
|
||||
let sent2 = bob.pop_sent_msg().await;
|
||||
let info = bob
|
||||
@@ -134,9 +149,17 @@ async fn accept_call() -> Result<CallSetup> {
|
||||
assert_eq!(call_state(&bob, bob_call.id).await?, CallState::Active);
|
||||
|
||||
bob2.recv_msg_trash(&sent2).await;
|
||||
assert_text(&bob, bob_call.id, "Incoming call").await?;
|
||||
assert_text(&bob, bob_call.id, "Incoming video call").await?;
|
||||
bob2.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
|
||||
.get_matching(|evt| {
|
||||
matches!(
|
||||
evt,
|
||||
EventType::IncomingCallAccepted {
|
||||
from_this_device: false,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.await;
|
||||
let info = bob2
|
||||
.load_call_by_id(bob2_call.id)
|
||||
@@ -147,7 +170,7 @@ async fn accept_call() -> Result<CallSetup> {
|
||||
|
||||
// Alice receives the acceptance message
|
||||
alice.recv_msg_trash(&sent2).await;
|
||||
assert_text(&alice, alice_call.id, "Outgoing call").await?;
|
||||
assert_text(&alice, alice_call.id, "Outgoing video call").await?;
|
||||
let ev = alice
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. }))
|
||||
@@ -169,7 +192,7 @@ async fn accept_call() -> Result<CallSetup> {
|
||||
assert_eq!(call_state(&alice, alice_call.id).await?, CallState::Active);
|
||||
|
||||
alice2.recv_msg_trash(&sent2).await;
|
||||
assert_text(&alice2, alice2_call.id, "Outgoing call").await?;
|
||||
assert_text(&alice2, alice2_call.id, "Outgoing video call").await?;
|
||||
alice2
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. }))
|
||||
@@ -205,10 +228,21 @@ async fn test_accept_call_callee_ends() -> Result<()> {
|
||||
bob2_call,
|
||||
..
|
||||
} = accept_call().await?;
|
||||
assert_eq!(bob_call.id.get_state(&bob).await?, MessageState::InSeen);
|
||||
|
||||
// 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 sends an MDN to Alice.
|
||||
assert_eq!(
|
||||
bob.sql
|
||||
.count(
|
||||
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
|
||||
(bob_call.id, bob_call.from_id)
|
||||
)
|
||||
.await?,
|
||||
1
|
||||
);
|
||||
assert_text(&bob, bob_call.id, "Incoming video call\n<1 minute").await?;
|
||||
bob.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
@@ -219,7 +253,7 @@ async fn test_accept_call_callee_ends() -> Result<()> {
|
||||
));
|
||||
|
||||
bob2.recv_msg_trash(&sent3).await;
|
||||
assert_text(&bob2, bob2_call.id, "Incoming call\n<1 minute").await?;
|
||||
assert_text(&bob2, bob2_call.id, "Incoming video call\n<1 minute").await?;
|
||||
bob2.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
@@ -230,7 +264,7 @@ async fn test_accept_call_callee_ends() -> Result<()> {
|
||||
|
||||
// Alice receives the ending message
|
||||
alice.recv_msg_trash(&sent3).await;
|
||||
assert_text(&alice, alice_call.id, "Outgoing call\n<1 minute").await?;
|
||||
assert_text(&alice, alice_call.id, "Outgoing video call\n<1 minute").await?;
|
||||
alice
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
@@ -241,7 +275,7 @@ async fn test_accept_call_callee_ends() -> Result<()> {
|
||||
));
|
||||
|
||||
alice2.recv_msg_trash(&sent3).await;
|
||||
assert_text(&alice2, alice2_call.id, "Outgoing call\n<1 minute").await?;
|
||||
assert_text(&alice2, alice2_call.id, "Outgoing video call\n<1 minute").await?;
|
||||
alice2
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
@@ -271,7 +305,7 @@ async fn test_accept_call_caller_ends() -> Result<()> {
|
||||
|
||||
// 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?;
|
||||
assert_text(&alice, alice_call.id, "Outgoing video call\n<1 minute").await?;
|
||||
alice
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
@@ -283,7 +317,7 @@ async fn test_accept_call_caller_ends() -> Result<()> {
|
||||
));
|
||||
|
||||
alice2.recv_msg_trash(&sent3).await;
|
||||
assert_text(&alice2, alice2_call.id, "Outgoing call\n<1 minute").await?;
|
||||
assert_text(&alice2, alice2_call.id, "Outgoing video call\n<1 minute").await?;
|
||||
alice2
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
@@ -295,7 +329,7 @@ async fn test_accept_call_caller_ends() -> Result<()> {
|
||||
|
||||
// Bob receives the ending message
|
||||
bob.recv_msg_trash(&sent3).await;
|
||||
assert_text(&bob, bob_call.id, "Incoming call\n<1 minute").await?;
|
||||
assert_text(&bob, bob_call.id, "Incoming video call\n<1 minute").await?;
|
||||
bob.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
@@ -305,7 +339,7 @@ async fn test_accept_call_caller_ends() -> Result<()> {
|
||||
));
|
||||
|
||||
bob2.recv_msg_trash(&sent3).await;
|
||||
assert_text(&bob2, bob2_call.id, "Incoming call\n<1 minute").await?;
|
||||
assert_text(&bob2, bob2_call.id, "Incoming video call\n<1 minute").await?;
|
||||
bob2.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
@@ -333,8 +367,18 @@ async fn test_callee_rejects_call() -> Result<()> {
|
||||
} = 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_eq!(bob_call.id.get_state(&bob).await?, MessageState::InSeen);
|
||||
// Bob sends an MDN to Alice.
|
||||
assert_eq!(
|
||||
bob.sql
|
||||
.count(
|
||||
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
|
||||
(bob_call.id, bob_call.from_id)
|
||||
)
|
||||
.await?,
|
||||
1
|
||||
);
|
||||
assert_text(&bob, bob_call.id, "Declined call").await?;
|
||||
bob.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
@@ -375,6 +419,35 @@ async fn test_callee_rejects_call() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_callee_sees_contact_request_call() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let alice_chat = alice.create_chat(bob).await;
|
||||
alice
|
||||
.place_outgoing_call(alice_chat.id, PLACE_INFO.to_string(), true)
|
||||
.await?;
|
||||
let sent1 = alice.pop_sent_msg().await;
|
||||
let bob_call = bob.recv_msg(&sent1).await;
|
||||
// Bob can't end_call() because the contact request isn't accepted, but he can mark the call as
|
||||
// seen.
|
||||
markseen_msgs(bob, vec![bob_call.id]).await?;
|
||||
assert_eq!(bob_call.id.get_state(bob).await?, MessageState::InSeen);
|
||||
// Bob sends an MDN only to self so that an unaccepted contact can't know anything.
|
||||
assert_eq!(
|
||||
bob.sql
|
||||
.count(
|
||||
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
|
||||
(bob_call.id, ContactId::SELF)
|
||||
)
|
||||
.await?,
|
||||
1
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_caller_cancels_call() -> Result<()> {
|
||||
// Alice calls Bob
|
||||
@@ -425,7 +498,7 @@ async fn test_caller_cancels_call() -> Result<()> {
|
||||
// Test that message summary says it is a missed call.
|
||||
let bob_call_msg = Message::load_from_db(&bob, bob_call.id).await?;
|
||||
let summary = bob_call_msg.get_summary(&bob, None).await?;
|
||||
assert_eq!(summary.text, "📞 Missed call");
|
||||
assert_eq!(summary.text, "🎥 Missed call");
|
||||
|
||||
bob2.recv_msg_trash(&sent3).await;
|
||||
assert_text(&bob2, bob2_call.id, "Missed call").await?;
|
||||
@@ -525,13 +598,6 @@ async fn test_update_call_text() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_sdp_has_video() {
|
||||
assert!(sdp_has_video("foobar").is_err());
|
||||
assert_eq!(sdp_has_video(PLACE_INFO).unwrap(), false);
|
||||
assert_eq!(sdp_has_video(PLACE_INFO_VIDEO).unwrap(), true);
|
||||
}
|
||||
|
||||
/// Tests that calls are forwarded as text messages.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_forward_call() -> Result<()> {
|
||||
@@ -542,7 +608,7 @@ async fn test_forward_call() -> Result<()> {
|
||||
|
||||
let alice_bob_chat = alice.create_chat(bob).await;
|
||||
let alice_msg_id = alice
|
||||
.place_outgoing_call(alice_bob_chat.id, PLACE_INFO.to_string())
|
||||
.place_outgoing_call(alice_bob_chat.id, PLACE_INFO.to_string(), true)
|
||||
.await
|
||||
.context("Failed to place a call")?;
|
||||
let alice_call = Message::load_from_db(alice, alice_msg_id).await?;
|
||||
@@ -610,65 +676,3 @@ async fn test_end_text_call() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that partially downloaded "call ended"
|
||||
/// messages are not processed.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_no_partial_calls() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
|
||||
let seen = false;
|
||||
|
||||
// The messages in the test
|
||||
// have no `Date` on purpose,
|
||||
// so they are treated as new.
|
||||
let received_call = receive_imf(
|
||||
alice,
|
||||
b"From: bob@example.net\n\
|
||||
To: alice@example.org\n\
|
||||
Message-ID: <first@example.net>\n\
|
||||
Chat-Version: 1.0\n\
|
||||
Chat-Content: call\n\
|
||||
Chat-Webrtc-Room: YWFhYWFhYWFhCg==\n\
|
||||
\n\
|
||||
Hello, this is a call\n",
|
||||
seen,
|
||||
)
|
||||
.await?
|
||||
.unwrap();
|
||||
assert_eq!(received_call.msg_ids.len(), 1);
|
||||
let call_msg = Message::load_from_db(alice, received_call.msg_ids[0])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(call_msg.viewtype, Viewtype::Call);
|
||||
assert_eq!(call_state(alice, call_msg.id).await?, CallState::Alerting);
|
||||
|
||||
let imf_raw = b"From: bob@example.net\n\
|
||||
To: alice@example.org\n\
|
||||
Message-ID: <second@example.net>\n\
|
||||
In-Reply-To: <first@example.net>\n\
|
||||
Chat-Version: 1.0\n\
|
||||
Chat-Content: call-ended\n\
|
||||
\n\
|
||||
Call ended\n";
|
||||
receive_imf_from_inbox(
|
||||
alice,
|
||||
"second@example.net",
|
||||
imf_raw,
|
||||
seen,
|
||||
Some(imf_raw.len().try_into().unwrap()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// The call is still not ended.
|
||||
assert_eq!(call_state(alice, call_msg.id).await?, CallState::Alerting);
|
||||
|
||||
// Fully downloading the message ends the call.
|
||||
receive_imf_from_inbox(alice, "second@example.net", imf_raw, seen, None)
|
||||
.await
|
||||
.context("Failed to fully download end call message")?;
|
||||
assert_eq!(call_state(alice, call_msg.id).await?, CallState::Missed);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
875
src/chat.rs
875
src/chat.rs
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::Event;
|
||||
use crate::chatlist::get_archived_cnt;
|
||||
use crate::constants::{DC_GCL_ARCHIVED_ONLY, DC_GCL_NO_SPECIALS};
|
||||
use crate::ephemeral::Timer;
|
||||
@@ -815,15 +816,6 @@ async fn test_self_talk() -> Result<()> {
|
||||
assert!(msg.get_showpadlock());
|
||||
|
||||
let sent_msg = t.pop_sent_msg().await;
|
||||
let payload = sent_msg.payload();
|
||||
// Make sure the `To` field contains the address and not
|
||||
// "undisclosed recipients".
|
||||
// Otherwise Delta Chat core <1.153.0 assigns the message
|
||||
// to the trash chat.
|
||||
assert_eq!(
|
||||
payload.match_indices("To: <alice@example.org>\r\n").count(),
|
||||
1
|
||||
);
|
||||
|
||||
let t2 = TestContext::new_alice().await;
|
||||
t2.recv_msg(&sent_msg).await;
|
||||
@@ -1238,7 +1230,7 @@ async fn test_unarchive_if_muted() -> Result<()> {
|
||||
chat_id.set_visibility(&t, ChatVisibility::Archived).await?;
|
||||
set_muted(&t, chat_id, MuteDuration::Forever).await?;
|
||||
send_text_msg(&t, chat_id, "out".to_string()).await?;
|
||||
add_info_msg(&t, chat_id, "info", time()).await?;
|
||||
add_info_msg(&t, chat_id, "info").await?;
|
||||
assert_eq!(get_archived_cnt(&t).await?, 1);
|
||||
|
||||
// finally, unarchive on sending to not muted chat
|
||||
@@ -1249,6 +1241,96 @@ async fn test_unarchive_if_muted() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_marknoticed_all_chats() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
tcm.section("alice: create chats & promote them by sending a message");
|
||||
|
||||
let alice_chat_normal = alice
|
||||
.create_group_with_members("Chat (normal)", &[alice, bob])
|
||||
.await;
|
||||
send_text_msg(alice, alice_chat_normal, "Hi".to_string()).await?;
|
||||
|
||||
let alice_chat_muted = alice
|
||||
.create_group_with_members("Chat (muted)", &[alice, bob])
|
||||
.await;
|
||||
send_text_msg(alice, alice_chat_muted, "Hi".to_string()).await?;
|
||||
set_muted(&alice.ctx, alice_chat_muted, MuteDuration::Forever).await?;
|
||||
|
||||
let alice_chat_archived_and_muted = alice
|
||||
.create_group_with_members("Chat (archived and muted)", &[alice, bob])
|
||||
.await;
|
||||
send_text_msg(alice, alice_chat_archived_and_muted, "Hi".to_string()).await?;
|
||||
set_muted(
|
||||
&alice.ctx,
|
||||
alice_chat_archived_and_muted,
|
||||
MuteDuration::Forever,
|
||||
)
|
||||
.await?;
|
||||
alice_chat_archived_and_muted
|
||||
.set_visibility(&alice.ctx, ChatVisibility::Archived)
|
||||
.await?;
|
||||
|
||||
tcm.section("bob: receive messages, accept all chats and send a reply to each messsage");
|
||||
|
||||
while let Some(sent_msg) = alice.pop_sent_msg_opt(Duration::default()).await {
|
||||
let bob_message = bob.recv_msg(&sent_msg).await;
|
||||
let bob_chat_id = bob_message.chat_id;
|
||||
bob_chat_id.accept(bob).await?;
|
||||
send_text_msg(bob, bob_chat_id, "reply".to_string()).await?;
|
||||
}
|
||||
|
||||
tcm.section("alice: receive replies from bob");
|
||||
while let Some(sent_msg) = bob.pop_sent_msg_opt(Duration::default()).await {
|
||||
alice.recv_msg(&sent_msg).await;
|
||||
}
|
||||
// ensure chats have unread messages
|
||||
assert_eq!(alice_chat_normal.get_fresh_msg_cnt(alice).await?, 1);
|
||||
assert_eq!(alice_chat_muted.get_fresh_msg_cnt(alice).await?, 1);
|
||||
assert_eq!(
|
||||
alice_chat_archived_and_muted
|
||||
.get_fresh_msg_cnt(alice)
|
||||
.await?,
|
||||
1
|
||||
);
|
||||
|
||||
tcm.section("alice: mark as read");
|
||||
alice.evtracker.clear_events();
|
||||
marknoticed_all_chats(alice).await?;
|
||||
tcm.section("alice: check that chats are no longer unread and that chatlist update events were received");
|
||||
assert_eq!(alice_chat_normal.get_fresh_msg_cnt(alice).await?, 0);
|
||||
assert_eq!(alice_chat_muted.get_fresh_msg_cnt(alice).await?, 0);
|
||||
assert_eq!(
|
||||
alice_chat_archived_and_muted
|
||||
.get_fresh_msg_cnt(alice)
|
||||
.await?,
|
||||
0
|
||||
);
|
||||
|
||||
let emitted_events = alice.evtracker.take_events();
|
||||
for event in &[
|
||||
EventType::ChatlistItemChanged {
|
||||
chat_id: Some(alice_chat_normal),
|
||||
},
|
||||
EventType::ChatlistItemChanged {
|
||||
chat_id: Some(alice_chat_muted),
|
||||
},
|
||||
EventType::ChatlistItemChanged {
|
||||
chat_id: Some(alice_chat_archived_and_muted),
|
||||
},
|
||||
EventType::ChatlistItemChanged {
|
||||
chat_id: Some(DC_CHAT_ID_ARCHIVED_LINK),
|
||||
},
|
||||
] {
|
||||
assert!(emitted_events.iter().any(|Event { typ, .. }| typ == event));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_archive_fresh_msgs() -> Result<()> {
|
||||
let t = TestContext::new_alice().await;
|
||||
@@ -1637,7 +1719,7 @@ async fn test_set_mute_duration() {
|
||||
async fn test_add_info_msg() -> Result<()> {
|
||||
let t = TestContext::new().await;
|
||||
let chat_id = create_group(&t, "foo").await?;
|
||||
add_info_msg(&t, chat_id, "foo info", time()).await?;
|
||||
add_info_msg(&t, chat_id, "foo info").await?;
|
||||
|
||||
let msg = t.get_last_msg_in(chat_id).await;
|
||||
assert_eq!(msg.get_chat_id(), chat_id);
|
||||
@@ -1659,8 +1741,8 @@ async fn test_add_info_msg_with_cmd() -> Result<()> {
|
||||
chat_id,
|
||||
"foo bar info",
|
||||
SystemMessage::EphemeralTimerChanged,
|
||||
time(),
|
||||
None,
|
||||
time(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -2559,7 +2641,7 @@ async fn test_resend_own_message() -> Result<()> {
|
||||
);
|
||||
let msg_from = Contact::get_by_id(&fiona, msg.get_from_id()).await?;
|
||||
assert_eq!(msg_from.get_addr(), "alice@example.org");
|
||||
assert!(sent1_ts_sent < msg.timestamp_sent);
|
||||
assert!(sent1_ts_sent == msg.timestamp_sent);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2649,27 +2731,24 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
|
||||
join_securejoin(charlie, &qr).await.unwrap();
|
||||
|
||||
let request = charlie.pop_sent_msg().await;
|
||||
assert_eq!(request.recipients, "alice@example.org charlie@example.net");
|
||||
assert_eq!(request.recipients, "alice@example.org");
|
||||
|
||||
alice.recv_msg_trash(&request).await;
|
||||
}
|
||||
|
||||
tcm.section("Alice sends auth-required");
|
||||
tcm.section("Alice sends vc-pubkey");
|
||||
{
|
||||
let auth_required = alice.pop_sent_msg().await;
|
||||
assert_eq!(
|
||||
auth_required.recipients,
|
||||
"charlie@example.net alice@example.org"
|
||||
);
|
||||
let parsed = charlie.parse_msg(&auth_required).await;
|
||||
assert!(parsed.get_header(HeaderDef::AutocryptGossip).is_some());
|
||||
assert!(parsed.decoded_data_contains("charlie@example.net"));
|
||||
let vc_pubkey = alice.pop_sent_msg().await;
|
||||
assert_eq!(vc_pubkey.recipients, "charlie@example.net");
|
||||
let parsed = charlie.parse_msg(&vc_pubkey).await;
|
||||
assert!(parsed.get_header(HeaderDef::AutocryptGossip).is_none());
|
||||
assert_eq!(parsed.decoded_data_contains("charlie@example.net"), false);
|
||||
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
|
||||
|
||||
let parsed_by_bob = bob.parse_msg(&auth_required).await;
|
||||
let parsed_by_bob = bob.parse_msg(&vc_pubkey).await;
|
||||
assert!(parsed_by_bob.decrypting_failed);
|
||||
|
||||
charlie.recv_msg_trash(&auth_required).await;
|
||||
charlie.recv_msg_trash(&vc_pubkey).await;
|
||||
}
|
||||
|
||||
tcm.section("Charlie sends request-with-auth");
|
||||
@@ -2871,6 +2950,145 @@ async fn test_broadcast_multidev() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that, if the broadcast channel owner has multiple devices
|
||||
/// and they have diverging views on the recipients,
|
||||
/// it is synced when sending a member-addition message.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_broadcast_recipients_sync1() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice1 = &tcm.alice().await;
|
||||
let alice2 = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
let charlie = &tcm.charlie().await;
|
||||
for a in &[alice1, alice2] {
|
||||
a.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
}
|
||||
|
||||
// Alice1 creates a broadcast and adds Bob, but for some reason
|
||||
// (e.g. because alice2 runs an older version of DC),
|
||||
// Alice2 doesn't get to know about it
|
||||
let a1_broadcast_id = create_broadcast(alice1, "Channel".to_string()).await?;
|
||||
alice1.send_sync_msg().await.unwrap();
|
||||
alice1.pop_sent_msg().await;
|
||||
|
||||
let qr = get_securejoin_qr(alice1, Some(a1_broadcast_id))
|
||||
.await
|
||||
.unwrap();
|
||||
tcm.exec_securejoin_qr(bob, alice1, &qr).await;
|
||||
|
||||
// The first sync message got lost, so, alice2 doesn't know about the channel now
|
||||
sync(alice1, alice2).await;
|
||||
let a2_chatlist = Chatlist::try_load(alice2, 0, Some("Channel"), None).await?;
|
||||
assert!(a2_chatlist.is_empty());
|
||||
|
||||
// Alice1 adds Charlie to the broadcast channel,
|
||||
// and now, Alice2 receives the messages
|
||||
join_securejoin(charlie, &qr).await.unwrap();
|
||||
|
||||
let request = charlie.pop_sent_msg().await;
|
||||
alice1.recv_msg_trash(&request).await;
|
||||
alice2.recv_msg_trash(&request).await;
|
||||
|
||||
let vc_pubkey = alice1.pop_sent_msg().await;
|
||||
charlie.recv_msg_trash(&vc_pubkey).await;
|
||||
|
||||
let request_with_auth = charlie.pop_sent_msg().await;
|
||||
alice1.recv_msg_trash(&request_with_auth).await;
|
||||
alice2.recv_msg_trash(&request_with_auth).await;
|
||||
|
||||
let member_added = alice1.pop_sent_msg().await;
|
||||
let a2_charlie_added = alice2.recv_msg(&member_added).await;
|
||||
let _c_member_added = charlie.recv_msg(&member_added).await;
|
||||
let a2_chatlist = Chatlist::try_load(alice2, 0, Some("Channel"), None).await?;
|
||||
assert_eq!(a2_chatlist.get_msg_id(0)?.unwrap(), a2_charlie_added.id);
|
||||
|
||||
// Alice1 will now sync the full member list to Alice2:
|
||||
sync(alice1, alice2).await;
|
||||
let a2_bob_contact = alice2.add_or_lookup_contact_id(bob).await;
|
||||
let a2_charlie_contact = alice2.add_or_lookup_contact_id(charlie).await;
|
||||
let a2_chatlist = Chatlist::try_load(alice2, 0, Some("Channel"), None).await?;
|
||||
let msg_id = a2_chatlist.get_msg_id(0)?.unwrap();
|
||||
let a2_bob_added = Message::load_from_db(alice2, msg_id).await?;
|
||||
assert_ne!(a2_bob_added.id, a2_charlie_added.id);
|
||||
assert_eq!(
|
||||
a2_bob_added.text,
|
||||
stock_str::msg_add_member_local(alice2, a2_bob_contact, ContactId::UNDEFINED).await
|
||||
);
|
||||
assert_eq!(a2_bob_added.from_id, ContactId::SELF);
|
||||
assert_eq!(
|
||||
a2_bob_added.param.get_cmd(),
|
||||
SystemMessage::MemberAddedToGroup
|
||||
);
|
||||
assert_eq!(
|
||||
ContactId::new(
|
||||
a2_bob_added
|
||||
.param
|
||||
.get_int(Param::ContactAddedRemoved)
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap()
|
||||
),
|
||||
a2_bob_contact
|
||||
);
|
||||
|
||||
let a2_chat_members = get_chat_contacts(alice2, a2_charlie_added.chat_id).await?;
|
||||
assert!(a2_chat_members.contains(&a2_bob_contact));
|
||||
assert!(a2_chat_members.contains(&a2_charlie_contact));
|
||||
assert_eq!(a2_chat_members.len(), 2);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that, if the broadcast channel owner has multiple devices
|
||||
/// and they have diverging views on the recipients,
|
||||
/// sync messages only add members but don't remove them.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_broadcast_recipients_sync2() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice1 = &tcm.alice().await;
|
||||
let alice2 = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
let charlie = &tcm.charlie().await;
|
||||
for a in &[alice1, alice2] {
|
||||
a.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
}
|
||||
|
||||
let a1_broadcast_id = create_broadcast(alice1, "Channel".to_string()).await?;
|
||||
sync(alice1, alice2).await;
|
||||
|
||||
tcm.section("Alice1 adds Bob, but Alice2 misses it for some reason");
|
||||
let qr = get_securejoin_qr(alice1, Some(a1_broadcast_id))
|
||||
.await
|
||||
.unwrap();
|
||||
tcm.exec_securejoin_qr(bob, alice1, &qr).await;
|
||||
|
||||
tcm.section("Alice2 adds Charlie, but Alice1 misses it for some reason");
|
||||
let a2_broadcast_id = Chatlist::try_load(alice2, 0, Some("Channel"), None)
|
||||
.await?
|
||||
.get_chat_id(0)
|
||||
.unwrap();
|
||||
let qr = get_securejoin_qr(alice2, Some(a2_broadcast_id))
|
||||
.await
|
||||
.unwrap();
|
||||
tcm.exec_securejoin_qr(charlie, alice2, &qr).await;
|
||||
|
||||
tcm.section("The sync messages should correct the problem");
|
||||
sync(alice1, alice2).await;
|
||||
sync(alice2, alice1).await;
|
||||
|
||||
for (alice, broadcast_id) in [(alice1, a1_broadcast_id), (alice2, a2_broadcast_id)] {
|
||||
let bob_contact = alice.add_or_lookup_contact_id(bob).await;
|
||||
let charlie_contact = alice.add_or_lookup_contact_id(charlie).await;
|
||||
|
||||
let chat_members = get_chat_contacts(alice, broadcast_id).await?;
|
||||
assert!(chat_members.contains(&bob_contact));
|
||||
assert!(chat_members.contains(&charlie_contact));
|
||||
assert_eq!(chat_members.len(), 2);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// - Create a broadcast channel
|
||||
/// - Send a message into it in order to promote it
|
||||
/// - Add a contact
|
||||
@@ -2919,7 +3137,7 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
|
||||
assert_eq!(rcvd.get_info_type(), SystemMessage::GroupNameChanged);
|
||||
assert_eq!(
|
||||
rcvd.text,
|
||||
r#"Group name changed from "My Channel" to "New Channel name" by Alice."#
|
||||
r#"Channel name changed from "My Channel" to "New Channel name"."#
|
||||
);
|
||||
let bob_chat = Chat::load_from_db(bob, bob_chat.id).await?;
|
||||
assert_eq!(bob_chat.name, "New Channel name");
|
||||
@@ -2936,7 +3154,7 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
|
||||
let rcvd = bob.recv_msg(&sent).await;
|
||||
assert!(rcvd.get_override_sender_name().is_none());
|
||||
assert_eq!(rcvd.get_info_type(), SystemMessage::GroupImageChanged);
|
||||
assert_eq!(rcvd.text, "Group image changed by Alice.");
|
||||
assert_eq!(rcvd.text, "Channel image changed.");
|
||||
assert_eq!(rcvd.chat_id, bob_chat.id);
|
||||
|
||||
let bob_chat = Chat::load_from_db(bob, bob_chat.id).await?;
|
||||
@@ -2957,6 +3175,201 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_chat_description_basic() {
|
||||
test_chat_description("", false, Chattype::Group)
|
||||
.await
|
||||
.unwrap();
|
||||
// Don't test with broadcast channels,
|
||||
// because broadcast channels can only be joined via a QR code
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_chat_description_unpromoted_description() {
|
||||
test_chat_description(
|
||||
"Unpromoted description in the beginning",
|
||||
false,
|
||||
Chattype::Group,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Don't test with broadcast channels,
|
||||
// because broadcast channels can only be joined via a QR code
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_chat_description_qr() {
|
||||
test_chat_description("", true, Chattype::Group)
|
||||
.await
|
||||
.unwrap();
|
||||
test_chat_description("", true, Chattype::OutBroadcast)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_chat_description_unpromoted_description_qr() {
|
||||
test_chat_description(
|
||||
"Unpromoted description in the beginning",
|
||||
true,
|
||||
Chattype::Group,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
test_chat_description(
|
||||
"Unpromoted description in the beginning",
|
||||
true,
|
||||
Chattype::OutBroadcast,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn test_chat_description(
|
||||
initial_description: &str,
|
||||
join_via_qr: bool,
|
||||
chattype: Chattype,
|
||||
) -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let alice2 = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
alice.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
alice2.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
|
||||
tcm.section("Create a group chat, and add Bob");
|
||||
let alice_chat_id = if chattype == Chattype::Group {
|
||||
create_group(alice, "My Group").await?
|
||||
} else {
|
||||
create_broadcast(alice, "My Channel".to_string()).await?
|
||||
};
|
||||
sync(alice, alice2).await;
|
||||
|
||||
if !initial_description.is_empty() {
|
||||
set_chat_description(alice, alice_chat_id, initial_description).await?;
|
||||
|
||||
if chattype == Chattype::OutBroadcast {
|
||||
// Broadcast channels are always promoted, so, a message is sent:
|
||||
let sent = alice.pop_sent_msg().await;
|
||||
assert_eq!(
|
||||
sent.load_from_db().await.text,
|
||||
"You changed the chat description."
|
||||
);
|
||||
let rcvd = alice2.recv_msg(&sent).await;
|
||||
assert_eq!(rcvd.text, "You changed the chat description.");
|
||||
} else {
|
||||
sync(alice, alice2).await;
|
||||
}
|
||||
}
|
||||
|
||||
let alice2_chat_id = get_chat_id_by_grpid(
|
||||
alice2,
|
||||
&Chat::load_from_db(alice, alice_chat_id).await?.grpid,
|
||||
)
|
||||
.await?
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(
|
||||
get_chat_description(alice2, alice2_chat_id).await?,
|
||||
initial_description
|
||||
);
|
||||
|
||||
let bob_chat_id = if join_via_qr {
|
||||
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
|
||||
tcm.exec_securejoin_qr(bob, alice, &qr).await
|
||||
} else {
|
||||
let alice_bob_id = alice.add_or_lookup_contact_id(bob).await;
|
||||
add_contact_to_chat(alice, alice_chat_id, alice_bob_id).await?;
|
||||
let sent = alice.send_text(alice_chat_id, "promoting the group").await;
|
||||
bob.recv_msg(&sent).await.chat_id
|
||||
};
|
||||
assert_eq!(
|
||||
get_chat_description(bob, bob_chat_id).await?,
|
||||
initial_description
|
||||
);
|
||||
|
||||
for description in ["This is a cool chat", "", "ä ẟ 😂"] {
|
||||
tcm.section(&format!(
|
||||
"Alice sets the chat description to '{description}'"
|
||||
));
|
||||
set_chat_description(alice, alice_chat_id, description).await?;
|
||||
let sent = alice.pop_sent_msg().await;
|
||||
assert_eq!(
|
||||
sent.load_from_db().await.text,
|
||||
"You changed the chat description."
|
||||
);
|
||||
|
||||
tcm.section("Bob receives the description change");
|
||||
let parsed = MimeMessage::from_bytes(bob, sent.payload().as_bytes()).await?;
|
||||
assert_eq!(
|
||||
parsed.parts[0].msg,
|
||||
"[Chat description changed. To see this and other new features, please update the app]"
|
||||
);
|
||||
let rcvd = bob.recv_msg(&sent).await;
|
||||
assert_eq!(rcvd.get_info_type(), SystemMessage::GroupDescriptionChanged);
|
||||
assert_eq!(rcvd.text, "Chat description changed by alice@example.org.");
|
||||
|
||||
assert_eq!(get_chat_description(bob, rcvd.chat_id).await?, description);
|
||||
|
||||
tcm.section("Check Alice's second device");
|
||||
alice2.recv_msg(&sent).await;
|
||||
let alice2_chat_id = get_chat_id_by_grpid(
|
||||
alice2,
|
||||
&Chat::load_from_db(alice, alice_chat_id).await?.grpid,
|
||||
)
|
||||
.await?
|
||||
.unwrap()
|
||||
.0;
|
||||
|
||||
assert_eq!(
|
||||
get_chat_description(alice2, alice2_chat_id).await?,
|
||||
description
|
||||
);
|
||||
}
|
||||
|
||||
tcm.section("Alice calls set_chat_description() without actually changing the description");
|
||||
set_chat_description(alice, alice_chat_id, "ä ẟ 😂").await?;
|
||||
assert!(
|
||||
alice
|
||||
.pop_sent_msg_opt(Duration::from_secs(0))
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests explicitly setting an empty chat description
|
||||
/// doesn't trigger sending out a message
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_setting_empty_chat_description() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
tcm.section("Create a group chat, and add Bob in order to promote it");
|
||||
let alice_chat_id = create_group(alice, "My Group").await?;
|
||||
|
||||
add_contact_to_chat(
|
||||
alice,
|
||||
alice_chat_id,
|
||||
alice.add_or_lookup_contact_id(bob).await,
|
||||
)
|
||||
.await?;
|
||||
let _hi = alice.send_text(alice_chat_id, "hi").await;
|
||||
|
||||
set_chat_description(alice, alice_chat_id, "").await?;
|
||||
assert!(
|
||||
alice
|
||||
.pop_sent_msg_opt(Duration::from_secs(0))
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that directly after broadcast-securejoin,
|
||||
/// the brodacast is shown correctly on both devices.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -2987,14 +3400,17 @@ async fn test_broadcast_joining_golden() -> Result<()> {
|
||||
.await;
|
||||
|
||||
let alice_bob_contact = alice.add_or_lookup_contact_no_key(bob).await;
|
||||
let private_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!(private_chat.blocked, Blocked::Yes);
|
||||
assert!(
|
||||
ChatIdBlocked::lookup_by_contact(alice, alice_bob_contact.id)
|
||||
.await?
|
||||
.is_none()
|
||||
);
|
||||
let private_chat_id =
|
||||
ChatId::create_for_contact_with_blocked(alice, alice_bob_contact.id, Blocked::Not).await?;
|
||||
alice
|
||||
.golden_test_chat(
|
||||
private_chat.id,
|
||||
private_chat_id,
|
||||
"test_broadcast_joining_golden_private_chat",
|
||||
)
|
||||
.await;
|
||||
@@ -3125,7 +3541,7 @@ async fn test_broadcast_channel_protected_listid() -> Result<()> {
|
||||
.await?
|
||||
.grpid;
|
||||
|
||||
let parsed = mimeparser::MimeMessage::from_bytes(bob, sent.payload.as_bytes(), None).await?;
|
||||
let parsed = mimeparser::MimeMessage::from_bytes(bob, sent.payload.as_bytes()).await?;
|
||||
assert_eq!(
|
||||
parsed.get_mailinglist_header().unwrap(),
|
||||
format!("My Channel <{}>", alice_list_id)
|
||||
@@ -3236,6 +3652,11 @@ async fn test_remove_member_from_broadcast() -> Result<()> {
|
||||
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
|
||||
remove_contact_from_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
|
||||
|
||||
// Alice must not remember old members,
|
||||
// because we would like to remember the minimum information possible
|
||||
let past_contacts = get_past_chat_contacts(alice, alice_chat_id).await?;
|
||||
assert_eq!(past_contacts.len(), 0);
|
||||
|
||||
let remove_msg = alice.pop_sent_msg().await;
|
||||
let rcvd = bob.recv_msg(&remove_msg).await;
|
||||
assert_eq!(rcvd.text, "Member Me removed by alice@example.org.");
|
||||
@@ -3266,16 +3687,13 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
|
||||
join_securejoin(bob0, &qr).await.unwrap();
|
||||
|
||||
let request = bob0.pop_sent_msg().await;
|
||||
assert_eq!(request.recipients, "alice@example.org bob@example.net");
|
||||
assert_eq!(request.recipients, "alice@example.org");
|
||||
|
||||
alice.recv_msg_trash(&request).await;
|
||||
let auth_required = alice.pop_sent_msg().await;
|
||||
assert_eq!(
|
||||
auth_required.recipients,
|
||||
"bob@example.net alice@example.org"
|
||||
);
|
||||
let vc_pubkey = alice.pop_sent_msg().await;
|
||||
assert_eq!(vc_pubkey.recipients, "bob@example.net");
|
||||
|
||||
bob0.recv_msg_trash(&auth_required).await;
|
||||
bob0.recv_msg_trash(&vc_pubkey).await;
|
||||
let request_with_auth = bob0.pop_sent_msg().await;
|
||||
assert_eq!(
|
||||
request_with_auth.recipients,
|
||||
@@ -3291,7 +3709,7 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
|
||||
assert_eq!(rcvd.param.get_cmd(), SystemMessage::MemberAddedToGroup);
|
||||
|
||||
tcm.section("Bob's second device also receives these messages");
|
||||
bob1.recv_msg_trash(&auth_required).await;
|
||||
bob1.recv_msg_trash(&vc_pubkey).await;
|
||||
bob1.recv_msg_trash(&request_with_auth).await;
|
||||
bob1.recv_msg(&member_added).await;
|
||||
|
||||
@@ -3320,11 +3738,8 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
|
||||
remove_contact_from_chat(bob0, bob_chat_id, ContactId::SELF).await?;
|
||||
|
||||
let leave_msg = bob0.pop_sent_msg().await;
|
||||
let parsed = MimeMessage::from_bytes(bob1, leave_msg.payload().as_bytes(), None).await?;
|
||||
assert_eq!(
|
||||
parsed.parts[0].msg,
|
||||
stock_str::msg_group_left_remote(bob0).await
|
||||
);
|
||||
let parsed = MimeMessage::from_bytes(bob1, leave_msg.payload().as_bytes()).await?;
|
||||
assert_eq!(parsed.parts[0].msg, "bob@example.net left the group.");
|
||||
|
||||
let rcvd = bob1.recv_msg(&leave_msg).await;
|
||||
|
||||
@@ -3391,7 +3806,7 @@ async fn test_only_broadcast_owner_can_send_1() -> Result<()> {
|
||||
"Bob receives an answer, but shows it in 1:1 chat because of a fingerprint mismatch",
|
||||
);
|
||||
let rcvd = bob.recv_msg(&member_added).await;
|
||||
assert_eq!(rcvd.text, "I added member bob@example.net.");
|
||||
assert_eq!(rcvd.text, "Member bob@example.net was added.");
|
||||
|
||||
let bob_alice_chat_id = bob.get_chat(alice).await.id;
|
||||
assert_eq!(rcvd.chat_id, bob_alice_chat_id);
|
||||
@@ -3441,6 +3856,7 @@ async fn test_only_broadcast_owner_can_send_2() -> Result<()> {
|
||||
tcm.section("Now, Alice's fingerprint changes");
|
||||
|
||||
alice.sql.execute("DELETE FROM keypairs", ()).await?;
|
||||
*alice.self_public_key.lock().await = None;
|
||||
alice
|
||||
.sql
|
||||
.execute("DELETE FROM config WHERE keyname='key_id'", ())
|
||||
@@ -3451,14 +3867,20 @@ async fn test_only_broadcast_owner_can_send_2() -> Result<()> {
|
||||
.self_fingerprint
|
||||
.take();
|
||||
|
||||
tcm.section(
|
||||
"Alice sends a message, which is not put into the broadcast chat but into a 1:1 chat",
|
||||
);
|
||||
tcm.section("Alice sends a message, which is trashed");
|
||||
let sent = alice.send_text(alice_broadcast_id, "Hi").await;
|
||||
let rcvd = bob.recv_msg(&sent).await;
|
||||
assert_eq!(rcvd.text, "Hi");
|
||||
let bob_alice_chat_id = bob.get_chat(alice).await.id;
|
||||
assert_eq!(rcvd.chat_id, bob_alice_chat_id);
|
||||
bob.recv_msg_trash(&sent).await;
|
||||
let EventType::Warning(warning) = bob
|
||||
.evtracker
|
||||
.get_matching(|ev| matches!(ev, EventType::Warning(_)))
|
||||
.await
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(
|
||||
warning.contains("This sender is not allowed to encrypt with this secret key"),
|
||||
"Wrong warning: {warning}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -3493,7 +3915,7 @@ async fn test_sync_broadcast_avatar_and_name() -> Result<()> {
|
||||
assert_eq!(rcvd.param.get_cmd(), SystemMessage::GroupNameChanged);
|
||||
assert_eq!(
|
||||
rcvd.text,
|
||||
r#"You changed group name from "foo" to "New name"."#
|
||||
r#"Channel name changed from "foo" to "New name"."#
|
||||
);
|
||||
|
||||
let a2_broadcast_chat = Chat::load_from_db(alice2, a2_broadcast_id).await?;
|
||||
@@ -3507,7 +3929,7 @@ async fn test_sync_broadcast_avatar_and_name() -> Result<()> {
|
||||
let rcvd = alice1.recv_msg(&sent).await;
|
||||
assert_eq!(rcvd.chat_id, a1_broadcast_id);
|
||||
assert_eq!(rcvd.param.get_cmd(), SystemMessage::GroupImageChanged);
|
||||
assert_eq!(rcvd.text, "You changed the group image.");
|
||||
assert_eq!(rcvd.text, "Channel image changed.");
|
||||
|
||||
let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?;
|
||||
let avatar = a1_broadcast_chat.get_profile_image(alice1).await?.unwrap();
|
||||
@@ -3527,6 +3949,7 @@ async fn test_encrypt_decrypt_broadcast() -> Result<()> {
|
||||
let grpid = "grpid";
|
||||
|
||||
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
|
||||
let bob_alice_contact_id = bob.add_or_lookup_contact_id(alice).await;
|
||||
|
||||
tcm.section("Create a broadcast channel with Bob, and send a message");
|
||||
let alice_chat_id = create_out_broadcast_ex(
|
||||
@@ -3550,6 +3973,7 @@ async fn test_encrypt_decrypt_broadcast() -> Result<()> {
|
||||
)
|
||||
.await?;
|
||||
save_broadcast_secret(bob, bob_chat_id, secret).await?;
|
||||
add_to_chat_contacts_table(bob, time(), bob_chat_id, &[bob_alice_contact_id]).await?;
|
||||
|
||||
let sent = alice
|
||||
.send_text(alice_chat_id, "Symmetrically encrypted message")
|
||||
@@ -3615,15 +4039,15 @@ async fn test_chat_get_encryption_info() -> Result<()> {
|
||||
let chat_id = create_group(alice, "Group").await?;
|
||||
assert_eq!(
|
||||
chat_id.get_encryption_info(alice).await?,
|
||||
"End-to-end encryption available"
|
||||
"Messages are end-to-end encrypted."
|
||||
);
|
||||
|
||||
add_contact_to_chat(alice, chat_id, contact_bob).await?;
|
||||
assert_eq!(
|
||||
chat_id.get_encryption_info(alice).await?,
|
||||
"End-to-end encryption available\n\
|
||||
"Messages are end-to-end encrypted.\n\
|
||||
\n\
|
||||
bob@example.net\n\
|
||||
bob@example.net(bob@example.net)\n\
|
||||
CCCB 5AA9 F6E1 141C 9431\n\
|
||||
65F1 DB18 B18C BCF7 0487"
|
||||
);
|
||||
@@ -3631,13 +4055,13 @@ async fn test_chat_get_encryption_info() -> Result<()> {
|
||||
add_contact_to_chat(alice, chat_id, contact_fiona).await?;
|
||||
assert_eq!(
|
||||
chat_id.get_encryption_info(alice).await?,
|
||||
"End-to-end encryption available\n\
|
||||
"Messages are end-to-end encrypted.\n\
|
||||
\n\
|
||||
fiona@example.net\n\
|
||||
fiona@example.net(fiona@example.net)\n\
|
||||
C8BA 50BF 4AC1 2FAF 38D7\n\
|
||||
F657 DDFC 8E9F 3C79 9195\n\
|
||||
\n\
|
||||
bob@example.net\n\
|
||||
bob@example.net(bob@example.net)\n\
|
||||
CCCB 5AA9 F6E1 141C 9431\n\
|
||||
65F1 DB18 B18C BCF7 0487"
|
||||
);
|
||||
@@ -3645,13 +4069,13 @@ async fn test_chat_get_encryption_info() -> Result<()> {
|
||||
let email_chat = alice.create_email_chat(bob).await;
|
||||
assert_eq!(
|
||||
email_chat.id.get_encryption_info(alice).await?,
|
||||
"No encryption"
|
||||
"No encryption."
|
||||
);
|
||||
|
||||
alice.sql.execute("DELETE FROM public_keys", ()).await?;
|
||||
assert_eq!(
|
||||
chat_id.get_encryption_info(alice).await?,
|
||||
"End-to-end encryption available\n\
|
||||
"Messages are end-to-end encrypted.\n\
|
||||
\n\
|
||||
fiona@example.net\n\
|
||||
(key missing)\n\
|
||||
@@ -4309,6 +4733,10 @@ async fn test_sync_broadcast_and_send_message() -> Result<()> {
|
||||
vec![a2b_contact_id]
|
||||
);
|
||||
|
||||
// alice2's smeared clock may be behind alice1's one, so we need to work around "hi" appearing
|
||||
// before "You joined the channel." for bob. alice1 makes 3 more calls of
|
||||
// create_smeared_timestamp() than alice2 does as of 2026-03-10.
|
||||
SystemTime::shift(Duration::from_secs(3));
|
||||
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;
|
||||
@@ -4373,7 +4801,7 @@ async fn test_sync_name() -> Result<()> {
|
||||
assert_eq!(rcvd.to_id, ContactId::SELF);
|
||||
assert_eq!(
|
||||
rcvd.text,
|
||||
"You changed group name from \"Channel\" to \"Broadcast channel 42\"."
|
||||
"Channel name changed 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)
|
||||
@@ -4443,6 +4871,22 @@ async fn test_sync_create_group() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_broadcast_contacts_are_hidden() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let alice_chat_id = create_broadcast(alice, "Channel".to_string()).await?;
|
||||
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
|
||||
tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
send_text_msg(alice, alice_chat_id, "hello".to_string()).await?;
|
||||
bob.recv_msg(&alice.pop_sent_msg().await).await;
|
||||
assert_eq!(Contact::get_all(alice, 0, None).await?.len(), 0);
|
||||
assert_eq!(Contact::get_all(bob, 0, None).await?.len(), 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests sending JPEG image with .png extension.
|
||||
///
|
||||
/// This is a regression test, previously sending failed
|
||||
@@ -4507,7 +4951,7 @@ async fn test_info_not_referenced() -> Result<()> {
|
||||
|
||||
let bob_received_message = tcm.send_recv_accept(alice, bob, "Hi!").await;
|
||||
let bob_chat_id = bob_received_message.chat_id;
|
||||
add_info_msg(bob, bob_chat_id, "Some info", create_smeared_timestamp(bob)).await?;
|
||||
add_info_msg(bob, bob_chat_id, "Some info").await?;
|
||||
|
||||
// Bob sends a message.
|
||||
// This message should reference Alice's "Hi!" message and not the info message.
|
||||
@@ -5252,6 +5696,135 @@ async fn test_send_delete_request_no_encryption() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_forward_msgs_2ctx() -> 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 alice_sent = alice.send_text(alice_chat.id, "hi").await;
|
||||
let bob_alice_msg = bob.recv_msg(&alice_sent).await;
|
||||
let bob_chat_id = bob_alice_msg.chat_id;
|
||||
bob_chat_id.accept(bob).await?;
|
||||
|
||||
let bob_text = "Hi, did you know we're using the same device so i have access to your profile?";
|
||||
let bob_sent = bob.send_text(bob_chat_id, bob_text).await;
|
||||
alice.recv_msg(&bob_sent).await;
|
||||
let alice_chat_len = alice_chat.id.get_msg_cnt(alice).await?;
|
||||
|
||||
forward_msgs_2ctx(
|
||||
bob,
|
||||
&[bob_alice_msg.id, bob_sent.sender_msg_id],
|
||||
alice,
|
||||
alice_chat.id,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(alice_chat.id.get_msg_cnt(alice).await?, alice_chat_len + 2);
|
||||
let msg = alice.get_last_msg().await;
|
||||
assert!(msg.is_forwarded());
|
||||
assert_eq!(msg.text, bob_text);
|
||||
assert_eq!(msg.from_id, ContactId::SELF);
|
||||
|
||||
let sent = alice.pop_sent_msg().await;
|
||||
let msg = bob.recv_msg(&sent).await;
|
||||
assert!(msg.is_forwarded());
|
||||
assert_eq!(msg.text, bob_text);
|
||||
assert_eq!(msg.from_id, bob_alice_msg.from_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_forward_msgs_2ctx_with_file() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
// First, establish a chat between Alice and Bob to have the chat IDs
|
||||
let alice_chat = alice.create_chat(bob).await;
|
||||
let alice_initial = alice.send_text(alice_chat.id, "hi").await;
|
||||
let bob_alice_msg = bob.recv_msg(&alice_initial).await;
|
||||
let bob_chat_id = bob_alice_msg.chat_id;
|
||||
bob_chat_id.accept(bob).await?;
|
||||
|
||||
// Alice sends a message with an attached file to her self-chat
|
||||
let alice_self_chat = alice.get_self_chat().await;
|
||||
let file_bytes = b"test file content";
|
||||
let file = alice.get_blobdir().join("test.txt");
|
||||
tokio::fs::write(&file, file_bytes).await?;
|
||||
|
||||
let mut msg = Message::new(Viewtype::File);
|
||||
msg.set_file_and_deduplicate(alice, &file, Some("test.txt"), Some("text/plain"))?;
|
||||
msg.set_text("Here's a file".to_string());
|
||||
|
||||
alice.send_msg(alice_self_chat.id, &mut msg).await;
|
||||
let alice_self_msg = alice.get_last_msg().await;
|
||||
|
||||
// Verify the file exists in Alice's blobdir
|
||||
assert_eq!(alice_self_msg.viewtype, Viewtype::File);
|
||||
let alice_original_file_path = alice_self_msg.get_file(alice).unwrap();
|
||||
let alice_original_content = tokio::fs::read(&alice_original_file_path).await?;
|
||||
assert_eq!(alice_original_content, file_bytes);
|
||||
|
||||
// Alice forwards the message to Bob using forward_msgs_2ctx
|
||||
forward_msgs_2ctx(alice, &[alice_self_msg.id], bob, bob_chat_id).await?;
|
||||
|
||||
// Bob should have the forwarded message with the file in his database
|
||||
let bob_msg = bob.get_last_msg().await;
|
||||
assert_eq!(bob_msg.viewtype, Viewtype::File);
|
||||
assert!(bob_msg.is_forwarded());
|
||||
assert_eq!(bob_msg.text, "Here's a file");
|
||||
assert_eq!(bob_msg.from_id, ContactId::SELF);
|
||||
|
||||
// Verify Bob has the file in his blobdir with correct content
|
||||
let bob_file_path = bob_msg.get_file(bob).unwrap();
|
||||
let bob_file_content = tokio::fs::read(&bob_file_path).await?;
|
||||
assert_eq!(bob_file_content, file_bytes);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_forward_msgs_2ctx_missing_blob() -> 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 alice_initial = alice.send_text(alice_chat.id, "hi").await;
|
||||
let bob_alice_msg = bob.recv_msg(&alice_initial).await;
|
||||
let bob_chat_id = bob_alice_msg.chat_id;
|
||||
bob_chat_id.accept(bob).await?;
|
||||
// Alice sends a file to her self-chat
|
||||
let alice_self_chat = alice.get_self_chat().await;
|
||||
let file_bytes = b"test content";
|
||||
let file = alice.get_blobdir().join("test.txt");
|
||||
tokio::fs::write(&file, file_bytes).await?;
|
||||
|
||||
let mut msg = Message::new(Viewtype::File);
|
||||
msg.set_file_and_deduplicate(alice, &file, Some("test.txt"), Some("text/plain"))?;
|
||||
msg.set_text("File message".to_string());
|
||||
|
||||
alice.send_msg(alice_self_chat.id, &mut msg).await;
|
||||
let alice_self_msg = alice.get_last_msg().await;
|
||||
|
||||
// Delete the blob file from Alice's blobdir to simulate a missing file
|
||||
let alice_file_path = alice_self_msg.get_file(alice).unwrap();
|
||||
tokio::fs::remove_file(&alice_file_path).await?;
|
||||
|
||||
// Alice tries to forward the message - this should fail with an error
|
||||
let result = forward_msgs_2ctx(alice, &[alice_self_msg.id], bob, bob_chat_id).await;
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Failed to copy blob file")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that in multi-device setup
|
||||
/// second device learns the key of a contact
|
||||
/// via Autocrypt-Gossip in 1:1 chats.
|
||||
|
||||
@@ -76,7 +76,7 @@ impl Chatlist {
|
||||
/// the pseudo-chat DC_CHAT_ID_ARCHIVED_LINK is added if there are *any* archived
|
||||
/// chats
|
||||
/// - the flag DC_GCL_FOR_FORWARDING sorts "Saved messages" to the top of the chatlist
|
||||
/// and hides the device-chat and contact requests
|
||||
/// and hides the device-chat, contact requests and incoming broadcasts.
|
||||
/// typically used on forwarding, may be combined with DC_GCL_NO_SPECIALS
|
||||
/// - if the flag DC_GCL_NO_SPECIALS is set, archive link is not added
|
||||
/// to the list (may be used eg. for selecting chats on forwarding, the flag is
|
||||
@@ -185,7 +185,7 @@ impl Chatlist {
|
||||
warn!(context, "Cannot update special chat names: {err:#}.")
|
||||
}
|
||||
|
||||
let str_like_cmd = format!("%{query}%");
|
||||
let str_like_cmd = format!("%{}%", query.to_lowercase());
|
||||
context
|
||||
.sql
|
||||
.query_map_vec(
|
||||
@@ -201,7 +201,7 @@ impl Chatlist {
|
||||
ORDER BY timestamp DESC, id DESC LIMIT 1)
|
||||
WHERE c.id>9 AND c.id!=?2
|
||||
AND c.blocked!=1
|
||||
AND c.name LIKE ?3
|
||||
AND IFNULL(c.name_normalized,c.name) LIKE ?3
|
||||
AND (NOT ?4 OR EXISTS (SELECT 1 FROM msgs m WHERE m.chat_id = c.id AND m.state == ?5 AND hidden=0))
|
||||
GROUP BY c.id
|
||||
ORDER BY IFNULL(m.timestamp,c.created_timestamp) DESC, m.id DESC;",
|
||||
@@ -224,8 +224,9 @@ impl Chatlist {
|
||||
let process_rows = |rows: rusqlite::AndThenRows<_>| {
|
||||
rows.filter_map(|row: std::result::Result<(_, _, Params, _), _>| match row {
|
||||
Ok((chat_id, typ, param, msg_id)) => {
|
||||
if typ == Chattype::Mailinglist
|
||||
&& param.get(Param::ListPost).is_none_or_empty()
|
||||
if typ == Chattype::InBroadcast
|
||||
|| (typ == Chattype::Mailinglist
|
||||
&& param.get(Param::ListPost).is_none_or_empty())
|
||||
{
|
||||
None
|
||||
} else {
|
||||
@@ -396,8 +397,6 @@ impl Chatlist {
|
||||
if lastmsg.from_id == ContactId::SELF {
|
||||
None
|
||||
} else if chat.typ == Chattype::Group
|
||||
|| chat.typ == Chattype::OutBroadcast
|
||||
|| chat.typ == Chattype::InBroadcast
|
||||
|| chat.typ == Chattype::Mailinglist
|
||||
|| chat.is_self_talk()
|
||||
{
|
||||
@@ -471,10 +470,11 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::chat::save_msgs;
|
||||
use crate::chat::{
|
||||
add_contact_to_chat, create_group, get_chat_contacts, remove_contact_from_chat,
|
||||
send_text_msg,
|
||||
add_contact_to_chat, create_broadcast, create_group, get_chat_contacts,
|
||||
remove_contact_from_chat, send_text_msg, set_chat_name,
|
||||
};
|
||||
use crate::receive_imf::receive_imf;
|
||||
use crate::securejoin::get_securejoin_qr;
|
||||
use crate::stock_str::StockMessage;
|
||||
use crate::test_utils::TestContext;
|
||||
use crate::test_utils::TestContextManager;
|
||||
@@ -482,7 +482,7 @@ mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_try_load() {
|
||||
async fn test_try_load() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let bob = &tcm.bob().await;
|
||||
let chat_id1 = create_group(bob, "a chat").await.unwrap();
|
||||
@@ -552,6 +552,15 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(chats.len(), 1);
|
||||
|
||||
let chat_id = create_group(bob, "Δ-chat").await.unwrap();
|
||||
let chats = Chatlist::try_load(bob, 0, Some("δ"), None).await?;
|
||||
assert_eq!(chats.len(), 1);
|
||||
assert_eq!(chats.ids[0].0, chat_id);
|
||||
set_chat_name(bob, chat_id, "abcδe").await?;
|
||||
let chats = Chatlist::try_load(bob, 0, Some("Δ"), None).await?;
|
||||
assert_eq!(chats.len(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -589,6 +598,41 @@ mod tests {
|
||||
assert_eq!(chats.len(), 1);
|
||||
}
|
||||
|
||||
/// Test that DC_CHAT_TYPE_IN_BROADCAST are hidden
|
||||
/// and DC_CHAT_TYPE_OUT_BROADCAST are shown in chatlist for forwarding.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_broadcast_visiblity_on_forward() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let alice_broadcast_a_id = create_broadcast(alice, "Channel Alice".to_string()).await?;
|
||||
let qr = get_securejoin_qr(alice, Some(alice_broadcast_a_id))
|
||||
.await
|
||||
.unwrap();
|
||||
let bob_broadcast_a_id = tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
let bob_broadcast_b_id = create_broadcast(bob, "Channel Bob".to_string()).await?;
|
||||
|
||||
let chats = Chatlist::try_load(bob, DC_GCL_FOR_FORWARDING, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
!chats
|
||||
.iter()
|
||||
.any(|(chat_id, _)| chat_id == &bob_broadcast_a_id),
|
||||
"alice broadcast is not shown in bobs forwarding chatlist"
|
||||
);
|
||||
assert!(
|
||||
chats
|
||||
.iter()
|
||||
.any(|(chat_id, _)| chat_id == &bob_broadcast_b_id),
|
||||
"bobs own broadcast is shown in his forwarding chatlist"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_search_special_chat_names() {
|
||||
let t = TestContext::new_alice().await;
|
||||
@@ -797,6 +841,32 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_no_summary_prefix_for_channel() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = tcm.alice().await;
|
||||
let bob = tcm.bob().await;
|
||||
|
||||
let alice_chat_id = create_broadcast(&alice, "alice's channel".to_string()).await?;
|
||||
let qr = get_securejoin_qr(&alice, Some(alice_chat_id)).await?;
|
||||
tcm.exec_securejoin_qr(&bob, &alice, &qr).await;
|
||||
|
||||
send_text_msg(&alice, alice_chat_id, "hi".into()).await?;
|
||||
let sent1 = alice.pop_sent_msg().await;
|
||||
let chatlist = Chatlist::try_load(&alice, 0, None, None).await?;
|
||||
let summary = chatlist.get_summary(&alice, 0, None).await?;
|
||||
assert!(summary.prefix.is_none());
|
||||
assert_eq!(summary.text, "hi");
|
||||
|
||||
bob.recv_msg(&sent1).await;
|
||||
let chatlist = Chatlist::try_load(&bob, 0, None, None).await?;
|
||||
let summary = chatlist.get_summary(&bob, 0, None).await?;
|
||||
assert!(summary.prefix.is_none());
|
||||
assert_eq!(summary.text, "hi");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_load_broken() {
|
||||
let t = TestContext::new_bob().await;
|
||||
|
||||
@@ -7,6 +7,7 @@ use colorutils_rs::{Oklch, Rgb, TransferFunction};
|
||||
use sha1::{Digest, Sha1};
|
||||
|
||||
/// Converts an identifier to Hue angle.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn str_to_angle(s: &str) -> f32 {
|
||||
let bytes = s.as_bytes();
|
||||
let result = Sha1::digest(bytes);
|
||||
@@ -19,6 +20,7 @@ fn str_to_angle(s: &str) -> f32 {
|
||||
///
|
||||
/// 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.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn rgb_to_u32(rgb: Rgb<u8>) -> u32 {
|
||||
65536 * u32::from(rgb.r) + 256 * u32::from(rgb.g) + u32::from(rgb.b)
|
||||
}
|
||||
|
||||
240
src/config.rs
240
src/config.rs
@@ -13,15 +13,14 @@ use strum_macros::{AsRefStr, Display, EnumIter, EnumString};
|
||||
use tokio::fs;
|
||||
|
||||
use crate::blob::BlobObject;
|
||||
use crate::configure::EnteredLoginParam;
|
||||
use crate::context::Context;
|
||||
use crate::events::EventType;
|
||||
use crate::log::LogExt;
|
||||
use crate::mimefactory::RECOMMENDED_FILE_SIZE;
|
||||
use crate::provider::{Provider, get_provider_by_id};
|
||||
use crate::provider::Provider;
|
||||
use crate::sync::{self, Sync::*, SyncData};
|
||||
use crate::tools::get_abs_path;
|
||||
use crate::transport::ConfiguredLoginParam;
|
||||
use crate::tools::{get_abs_path, time};
|
||||
use crate::transport::{ConfiguredLoginParam, add_pseudo_transport, send_sync_transports};
|
||||
use crate::{constants, stats};
|
||||
|
||||
/// The available configuration keys.
|
||||
@@ -144,11 +143,11 @@ pub enum Config {
|
||||
|
||||
/// Send BCC copy to self.
|
||||
///
|
||||
/// Should be enabled for multidevice setups.
|
||||
/// Default is 0 for chatmail accounts, 1 otherwise.
|
||||
/// Should be enabled for multi-device setups.
|
||||
///
|
||||
/// This is automatically enabled when importing/exporting a backup,
|
||||
/// setting up a second device, or receiving a sync message.
|
||||
#[strum(props(default = "0"))]
|
||||
BccSelf,
|
||||
|
||||
/// True if Message Delivery Notifications (read receipts) should
|
||||
@@ -156,18 +155,6 @@ pub enum Config {
|
||||
#[strum(props(default = "1"))]
|
||||
MdnsEnabled,
|
||||
|
||||
/// True if chat messages should be moved to a separate folder. Auto-sent messages like sync
|
||||
/// ones are moved there anyway.
|
||||
#[strum(props(default = "1"))]
|
||||
MvboxMove,
|
||||
|
||||
/// Watch for new messages in the "Mvbox" (aka DeltaChat folder) only.
|
||||
///
|
||||
/// This will not entirely disable other folders, e.g. the spam folder will also still
|
||||
/// be watched for new messages.
|
||||
#[strum(props(default = "0"))]
|
||||
OnlyFetchMvbox,
|
||||
|
||||
/// Whether to show classic emails or only chat messages.
|
||||
#[strum(props(default = "2"))] // also change ShowEmails.default() on changes
|
||||
ShowEmails,
|
||||
@@ -176,11 +163,6 @@ pub enum Config {
|
||||
#[strum(props(default = "0"))] // also change MediaQuality.default() on changes
|
||||
MediaQuality,
|
||||
|
||||
/// If set to "1", then existing messages are considered to be already fetched.
|
||||
/// This flag is reset after successful configuration.
|
||||
#[strum(props(default = "1"))]
|
||||
FetchedExistingMsgs,
|
||||
|
||||
/// Timer in seconds after which the message is deleted from the
|
||||
/// server.
|
||||
///
|
||||
@@ -200,11 +182,7 @@ pub enum Config {
|
||||
#[strum(props(default = "0"))]
|
||||
DeleteDeviceAfter,
|
||||
|
||||
/// Move messages to the Trash folder instead of marking them "\Deleted". Overrides
|
||||
/// `ProviderOptions::delete_to_trash`.
|
||||
DeleteToTrash,
|
||||
|
||||
/// The primary email address. Also see `SecondaryAddrs`.
|
||||
/// The primary email address.
|
||||
ConfiguredAddr,
|
||||
|
||||
/// List of configured IMAP servers as a JSON array.
|
||||
@@ -278,12 +256,6 @@ pub enum Config {
|
||||
/// Configured folder for incoming messages.
|
||||
ConfiguredInboxFolder,
|
||||
|
||||
/// Configured folder for chat messages.
|
||||
ConfiguredMvboxFolder,
|
||||
|
||||
/// Configured "Trash" folder.
|
||||
ConfiguredTrashFolder,
|
||||
|
||||
/// Unix timestamp of the last successful configuration.
|
||||
ConfiguredTimestamp,
|
||||
|
||||
@@ -306,10 +278,6 @@ pub enum Config {
|
||||
/// Meant to help profile owner to differ between profiles with similar names.
|
||||
PrivateTag,
|
||||
|
||||
/// All secondary self addresses separated by spaces
|
||||
/// (`addr1@example.org addr2@example.org addr3@example.org`)
|
||||
SecondaryAddrs,
|
||||
|
||||
/// Read-only core version string.
|
||||
#[strum(serialize = "sys.version")]
|
||||
SysVersion,
|
||||
@@ -345,10 +313,6 @@ pub enum Config {
|
||||
/// Timestamp of the last `CantDecryptOutgoingMsgs` notification.
|
||||
LastCantDecryptOutgoingMsgs,
|
||||
|
||||
/// To how many seconds to debounce scan_all_folders. Used mainly in tests, to disable debouncing completely.
|
||||
#[strum(props(default = "60"))]
|
||||
ScanAllFoldersDebounceSecs,
|
||||
|
||||
/// Whether to avoid using IMAP IDLE even if the server supports it.
|
||||
///
|
||||
/// This is a developer option for testing "fake idle".
|
||||
@@ -359,7 +323,17 @@ pub enum Config {
|
||||
DonationRequestNextCheck,
|
||||
|
||||
/// Defines the max. size (in bytes) of messages downloaded automatically.
|
||||
///
|
||||
/// For messages with large attachments, two messages are sent:
|
||||
/// a Pre-Message containing metadata and text and a Post-Message additionally
|
||||
/// containing the attachment. NB: Some "extra" metadata like avatars and gossiped
|
||||
/// encryption keys is stripped from post-messages to save traffic.
|
||||
/// Pre-Messages are shown as placeholder messages. They can be downloaded fully using
|
||||
/// `MsgId::download_full()` later. Post-Messages are automatically downloaded if they are
|
||||
/// smaller than the download_limit. Other messages are always auto-downloaded.
|
||||
///
|
||||
/// 0 = no limit.
|
||||
/// Changes only affect future messages.
|
||||
#[strum(props(default = "0"))]
|
||||
DownloadLimit,
|
||||
|
||||
@@ -443,14 +417,24 @@ pub enum Config {
|
||||
/// using this still run unmodified code.
|
||||
TestHooks,
|
||||
|
||||
/// Return an error from `receive_imf_inner()` for a fully downloaded message. For tests.
|
||||
FailOnReceivingFullMsg,
|
||||
/// Return an error from `receive_imf_inner()`. For tests.
|
||||
SimulateReceiveImfError,
|
||||
|
||||
/// Enable composing emails with Header Protection as defined in
|
||||
/// <https://www.rfc-editor.org/rfc/rfc9788.html> "Header Protection for Cryptographically
|
||||
/// Protected Email".
|
||||
#[strum(props(default = "1"))]
|
||||
StdHeaderProtectionComposing,
|
||||
|
||||
/// Who can call me.
|
||||
///
|
||||
/// The options are from the `WhoCanCallMe` enum.
|
||||
#[strum(props(default = "1"))]
|
||||
WhoCanCallMe,
|
||||
|
||||
/// Experimental option denoting that the current profile is shared between multiple team members.
|
||||
/// For now, the only effect of this option is that seen flags are not synchronized.
|
||||
TeamProfile,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -468,7 +452,6 @@ impl Config {
|
||||
self,
|
||||
Self::Displayname
|
||||
| Self::MdnsEnabled
|
||||
| Self::MvboxMove
|
||||
| Self::ShowEmails
|
||||
| Self::Selfavatar
|
||||
| Self::Selfstatus,
|
||||
@@ -477,7 +460,7 @@ impl Config {
|
||||
|
||||
/// Whether the config option needs an IO scheduler restart to take effect.
|
||||
pub(crate) fn needs_io_restart(&self) -> bool {
|
||||
matches!(self, Config::MvboxMove | Config::OnlyFetchMvbox)
|
||||
matches!(self, Config::ConfiguredAddr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,7 +489,7 @@ impl Context {
|
||||
.into_owned()
|
||||
})
|
||||
}
|
||||
Config::SysVersion => Some((*constants::DC_VERSION_STR).clone()),
|
||||
Config::SysVersion => Some(constants::DC_VERSION_STR.to_string()),
|
||||
Config::SysMsgsizeMaxRecommended => Some(format!("{RECOMMENDED_FILE_SIZE}")),
|
||||
Config::SysConfigKeys => Some(get_config_keys_string()),
|
||||
_ => self.sql.get_raw_config(key.as_ref()).await?,
|
||||
@@ -523,10 +506,6 @@ impl Context {
|
||||
|
||||
// Default values
|
||||
let val = match key {
|
||||
Config::BccSelf => match Box::pin(self.is_chatmail()).await? {
|
||||
false => Some("1".to_string()),
|
||||
true => Some("0".to_string()),
|
||||
},
|
||||
Config::ConfiguredInboxFolder => Some("INBOX".to_string()),
|
||||
Config::DeleteServerAfter => {
|
||||
match !Box::pin(self.get_config_bool(Config::BccSelf)).await?
|
||||
@@ -593,15 +572,7 @@ impl Context {
|
||||
.get_config(key)
|
||||
.await?
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.map(|x| x != 0)
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Returns true if movebox ("DeltaChat" folder) should be watched.
|
||||
pub(crate) async fn should_watch_mvbox(&self) -> Result<bool> {
|
||||
Ok(self.get_config_bool(Config::MvboxMove).await?
|
||||
|| self.get_config_bool(Config::OnlyFetchMvbox).await?
|
||||
|| !self.get_config_bool(Config::IsChatmail).await?)
|
||||
.is_some_and(|x| x != 0))
|
||||
}
|
||||
|
||||
/// Returns true if sync messages should be sent.
|
||||
@@ -611,12 +582,6 @@ impl Context {
|
||||
&& !self.get_config_bool(Config::Bot).await?)
|
||||
}
|
||||
|
||||
/// Returns whether sync messages should be uploaded to the mvbox.
|
||||
pub(crate) async fn should_move_sync_msgs(&self) -> Result<bool> {
|
||||
Ok(self.get_config_bool(Config::MvboxMove).await?
|
||||
|| !self.get_config_bool(Config::IsChatmail).await?)
|
||||
}
|
||||
|
||||
/// Returns whether MDNs should be requested.
|
||||
pub(crate) async fn should_request_mdns(&self) -> Result<bool> {
|
||||
match self.get_config_bool_opt(Config::MdnsEnabled).await? {
|
||||
@@ -647,15 +612,14 @@ impl Context {
|
||||
Ok(val)
|
||||
}
|
||||
|
||||
/// Gets the configured provider, as saved in the `configured_provider` value.
|
||||
/// Gets the configured provider.
|
||||
///
|
||||
/// The provider is determined by `get_provider_info()` during configuration and then saved
|
||||
/// to the db in `param.save_to_database()`, together with all the other `configured_*` values.
|
||||
/// The provider is determined by the current primary transport.
|
||||
pub async fn get_configured_provider(&self) -> Result<Option<&'static Provider>> {
|
||||
if let Some(cfg) = self.get_config(Config::ConfiguredProvider).await? {
|
||||
return Ok(get_provider_by_id(&cfg));
|
||||
}
|
||||
Ok(None)
|
||||
let provider = ConfiguredLoginParam::load(self)
|
||||
.await?
|
||||
.and_then(|(_transport_id, param)| param.provider);
|
||||
Ok(provider)
|
||||
}
|
||||
|
||||
/// Gets configured "delete_device_after" value.
|
||||
@@ -692,9 +656,6 @@ impl Context {
|
||||
| Config::ProxyEnabled
|
||||
| Config::BccSelf
|
||||
| Config::MdnsEnabled
|
||||
| Config::MvboxMove
|
||||
| Config::OnlyFetchMvbox
|
||||
| Config::DeleteToTrash
|
||||
| Config::Configured
|
||||
| Config::Bot
|
||||
| Config::NotifyAboutWrongPw
|
||||
@@ -795,26 +756,68 @@ impl Context {
|
||||
.set_raw_config(key.as_ref(), value.map(|s| s.to_lowercase()).as_deref())
|
||||
.await?;
|
||||
}
|
||||
Config::MvboxMove => {
|
||||
self.sql.set_raw_config(key.as_ref(), value).await?;
|
||||
self.sql
|
||||
.set_raw_config(constants::DC_FOLDERS_CONFIGURED_KEY, None)
|
||||
.await?;
|
||||
}
|
||||
Config::ConfiguredAddr => {
|
||||
if self.is_configured().await? {
|
||||
bail!("Cannot change ConfiguredAddr");
|
||||
}
|
||||
if let Some(addr) = value {
|
||||
let Some(addr) = value else {
|
||||
bail!("Cannot unset configured_addr");
|
||||
};
|
||||
|
||||
if !self.is_configured().await? {
|
||||
info!(
|
||||
self,
|
||||
"Creating a pseudo configured account which will not be able to send or receive messages. Only meant for tests!"
|
||||
);
|
||||
ConfiguredLoginParam::from_json(&format!(
|
||||
r#"{{"addr":"{addr}","imap":[],"imap_user":"","imap_password":"","smtp":[],"smtp_user":"","smtp_password":"","certificate_checks":"Automatic","oauth2":false}}"#
|
||||
))?
|
||||
.save_to_transports_table(self, &EnteredLoginParam::default())
|
||||
.await?;
|
||||
add_pseudo_transport(self, addr).await?;
|
||||
self.sql
|
||||
.set_raw_config(Config::ConfiguredAddr.as_ref(), Some(addr))
|
||||
.await?;
|
||||
} else {
|
||||
self.sql
|
||||
.transaction(|transaction| {
|
||||
if transaction.query_row(
|
||||
"SELECT COUNT(*) FROM transports WHERE addr=?",
|
||||
(addr,),
|
||||
|row| {
|
||||
let res: i64 = row.get(0)?;
|
||||
Ok(res)
|
||||
},
|
||||
)? == 0
|
||||
{
|
||||
bail!("Address does not belong to any transport.");
|
||||
}
|
||||
transaction.execute(
|
||||
"UPDATE config SET value=? WHERE keyname='configured_addr'",
|
||||
(addr,),
|
||||
)?;
|
||||
|
||||
// Update the timestamp for the primary transport
|
||||
// so it becomes the first in `get_all_self_addrs()` list
|
||||
// and the list of relays distributed in the public key.
|
||||
// This ensures that messages will be sent
|
||||
// to the primary relay by the contacts
|
||||
// and will be fetched in background_fetch()
|
||||
// which only fetches from the primary transport.
|
||||
transaction
|
||||
.execute(
|
||||
"UPDATE transports SET add_timestamp=? WHERE addr=?",
|
||||
(time(), addr),
|
||||
)
|
||||
.context(
|
||||
"Failed to update add_timestamp for the new primary transport",
|
||||
)?;
|
||||
|
||||
// Clean up SMTP and IMAP APPEND queue.
|
||||
//
|
||||
// The messages in the queue have a different
|
||||
// From address so we cannot send them over
|
||||
// the new SMTP transport.
|
||||
transaction.execute("DELETE FROM smtp", ())?;
|
||||
transaction.execute("DELETE FROM imap_send", ())?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
send_sync_transports(self).await?;
|
||||
self.sql.uncache_raw_config("configured_addr").await;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
@@ -845,7 +848,7 @@ impl Context {
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
self.scheduler.interrupt_inbox().await;
|
||||
self.scheduler.interrupt_smtp().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -909,17 +912,7 @@ impl Context {
|
||||
/// This should only be used by test code and during configure.
|
||||
#[cfg(test)] // AEAP is disabled, but there are still tests for it
|
||||
pub(crate) async fn set_primary_self_addr(&self, primary_new: &str) -> Result<()> {
|
||||
self.quota.write().await.take();
|
||||
|
||||
// add old primary address (if exists) to secondary addresses
|
||||
let mut secondary_addrs = self.get_all_self_addrs().await?;
|
||||
// never store a primary address also as a secondary
|
||||
secondary_addrs.retain(|a| !addr_cmp(a, primary_new));
|
||||
self.set_config_internal(
|
||||
Config::SecondaryAddrs,
|
||||
Some(secondary_addrs.join(" ").as_str()),
|
||||
)
|
||||
.await?;
|
||||
self.quota.write().await.clear();
|
||||
|
||||
self.sql
|
||||
.set_raw_config(Config::ConfiguredAddr.as_ref(), Some(primary_new))
|
||||
@@ -928,24 +921,26 @@ impl Context {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns all primary and secondary self addresses.
|
||||
/// Returns all self addresses, newest first.
|
||||
pub(crate) async fn get_all_self_addrs(&self) -> Result<Vec<String>> {
|
||||
let primary_addrs = self.get_config(Config::ConfiguredAddr).await?.into_iter();
|
||||
let secondary_addrs = self.get_secondary_self_addrs().await?.into_iter();
|
||||
|
||||
Ok(primary_addrs.chain(secondary_addrs).collect())
|
||||
self.sql
|
||||
.query_map_vec(
|
||||
"SELECT addr FROM transports ORDER BY add_timestamp DESC",
|
||||
(),
|
||||
|row| {
|
||||
let addr: String = row.get(0)?;
|
||||
Ok(addr)
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns all secondary self addresses.
|
||||
pub(crate) async fn get_secondary_self_addrs(&self) -> Result<Vec<String>> {
|
||||
let secondary_addrs = self
|
||||
.get_config(Config::SecondaryAddrs)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
Ok(secondary_addrs
|
||||
.split_ascii_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect())
|
||||
self.sql.query_map_vec("SELECT addr FROM transports WHERE addr NOT IN (SELECT value FROM config WHERE keyname='configured_addr')", (), |row| {
|
||||
let addr: String = row.get(0)?;
|
||||
Ok(addr)
|
||||
}).await
|
||||
}
|
||||
|
||||
/// Returns the primary self address.
|
||||
@@ -968,5 +963,18 @@ fn get_config_keys_string() -> String {
|
||||
format!(" {keys} ")
|
||||
}
|
||||
|
||||
/// Returns all `ui.*` config keys that were set by the UI.
|
||||
pub async fn get_all_ui_config_keys(context: &Context) -> Result<Vec<String>> {
|
||||
let ui_keys = context
|
||||
.sql
|
||||
.query_map_vec(
|
||||
"SELECT keyname FROM config WHERE keyname GLOB 'ui.*' ORDER BY config.id",
|
||||
(),
|
||||
|row| Ok(row.get::<_, String>(0)?),
|
||||
)
|
||||
.await?;
|
||||
Ok(ui_keys)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod config_tests;
|
||||
|
||||
@@ -81,6 +81,37 @@ async fn test_ui_config() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_get_all_ui_config_keys() -> Result<()> {
|
||||
let t = TestContext::new().await;
|
||||
|
||||
t.set_ui_config("ui.android.screen_security", Some("safe"))
|
||||
.await?;
|
||||
t.set_ui_config("ui.lastchatid", Some("231")).await?;
|
||||
t.set_ui_config(
|
||||
"ui.desktop.webxdcBounds.528490",
|
||||
Some(r#"{"x":954,"y":356,"width":378,"height":671}"#),
|
||||
)
|
||||
.await?;
|
||||
t.set_ui_config(
|
||||
"ui.desktop.webxdcBounds.556543",
|
||||
Some(r#"{"x":954,"y":356,"width":378,"height":671}"#),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
get_all_ui_config_keys(&t).await?,
|
||||
vec![
|
||||
"ui.android.screen_security",
|
||||
"ui.lastchatid",
|
||||
"ui.desktop.webxdcBounds.528490",
|
||||
"ui.desktop.webxdcBounds.556543"
|
||||
]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Regression test for https://github.com/deltachat/deltachat-core-rust/issues/3012
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_set_config_bool() -> Result<()> {
|
||||
@@ -94,59 +125,6 @@ async fn test_set_config_bool() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_self_addrs() -> Result<()> {
|
||||
let alice = TestContext::new_alice().await;
|
||||
|
||||
assert!(alice.is_self_addr("alice@example.org").await?);
|
||||
assert_eq!(alice.get_all_self_addrs().await?, vec!["alice@example.org"]);
|
||||
assert!(!alice.is_self_addr("alice@alice.com").await?);
|
||||
|
||||
// Test adding the same primary address
|
||||
alice.set_primary_self_addr("alice@example.org").await?;
|
||||
alice.set_primary_self_addr("Alice@Example.Org").await?;
|
||||
assert_eq!(alice.get_all_self_addrs().await?, vec!["Alice@Example.Org"]);
|
||||
|
||||
// Test adding a new (primary) self address
|
||||
// The address is trimmed during configure by `LoginParam::from_database()`,
|
||||
// so `set_primary_self_addr()` doesn't have to trim it.
|
||||
alice.set_primary_self_addr("Alice@alice.com").await?;
|
||||
assert!(alice.is_self_addr("aliCe@example.org").await?);
|
||||
assert!(alice.is_self_addr("alice@alice.com").await?);
|
||||
assert_eq!(
|
||||
alice.get_all_self_addrs().await?,
|
||||
vec!["Alice@alice.com", "Alice@Example.Org"]
|
||||
);
|
||||
|
||||
// Check that the entry is not duplicated
|
||||
alice.set_primary_self_addr("alice@alice.com").await?;
|
||||
alice.set_primary_self_addr("alice@alice.com").await?;
|
||||
assert_eq!(
|
||||
alice.get_all_self_addrs().await?,
|
||||
vec!["alice@alice.com", "Alice@Example.Org"]
|
||||
);
|
||||
|
||||
// Test switching back
|
||||
alice.set_primary_self_addr("alice@example.org").await?;
|
||||
assert_eq!(
|
||||
alice.get_all_self_addrs().await?,
|
||||
vec!["alice@example.org", "alice@alice.com"]
|
||||
);
|
||||
|
||||
// Test setting a new primary self address, the previous self address
|
||||
// should be kept as a secondary self address
|
||||
alice.set_primary_self_addr("alice@alice.xyz").await?;
|
||||
assert_eq!(
|
||||
alice.get_all_self_addrs().await?,
|
||||
vec!["alice@alice.xyz", "alice@example.org", "alice@alice.com"]
|
||||
);
|
||||
assert!(alice.is_self_addr("alice@example.org").await?);
|
||||
assert!(alice.is_self_addr("alice@alice.com").await?);
|
||||
assert!(alice.is_self_addr("Alice@alice.xyz").await?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_mdns_default_behaviour() -> Result<()> {
|
||||
let t = &TestContext::new_alice().await;
|
||||
@@ -218,11 +196,11 @@ async fn test_sync() -> Result<()> {
|
||||
sync(&alice0, &alice1).await;
|
||||
assert_eq!(alice1.get_config_bool(Config::MdnsEnabled).await?, false);
|
||||
|
||||
for key in [Config::ShowEmails, Config::MvboxMove] {
|
||||
let val = alice0.get_config_bool(key).await?;
|
||||
alice0.set_config_bool(key, !val).await?;
|
||||
{
|
||||
let val = alice0.get_config_bool(Config::ShowEmails).await?;
|
||||
alice0.set_config_bool(Config::ShowEmails, !val).await?;
|
||||
sync(&alice0, &alice1).await;
|
||||
assert_eq!(alice1.get_config_bool(key).await?, !val);
|
||||
assert_eq!(alice1.get_config_bool(Config::ShowEmails).await?, !val);
|
||||
}
|
||||
|
||||
// `Config::SyncMsgs` mustn't be synced.
|
||||
@@ -278,7 +256,6 @@ async fn test_sync() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync message mustn't be sent if self-{status,avatar} is changed by a self-sent message.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_no_sync_on_self_sent_msg() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
@@ -288,16 +265,16 @@ async fn test_no_sync_on_self_sent_msg() -> Result<()> {
|
||||
a.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
}
|
||||
|
||||
let status = "Synced via usual message";
|
||||
let status = "Sent via usual message";
|
||||
alice0.set_config(Config::Selfstatus, Some(status)).await?;
|
||||
alice0.send_sync_msg().await?;
|
||||
alice0.pop_sent_sync_msg().await;
|
||||
alice0.pop_sent_msg().await;
|
||||
let status1 = "Synced via sync message";
|
||||
alice1.set_config(Config::Selfstatus, Some(status1)).await?;
|
||||
tcm.send_recv(alice0, alice1, "hi Alice!").await;
|
||||
assert_eq!(
|
||||
alice1.get_config(Config::Selfstatus).await?,
|
||||
Some(status.to_string())
|
||||
Some(status1.to_string())
|
||||
);
|
||||
sync(alice1, alice0).await;
|
||||
assert_eq!(
|
||||
@@ -315,7 +292,7 @@ async fn test_no_sync_on_self_sent_msg() -> Result<()> {
|
||||
.set_config(Config::Selfavatar, Some(file.to_str().unwrap()))
|
||||
.await?;
|
||||
alice0.send_sync_msg().await?;
|
||||
alice0.pop_sent_sync_msg().await;
|
||||
alice0.pop_sent_msg().await;
|
||||
let file = alice1.dir.path().join("avatar.jpg");
|
||||
let bytes = include_bytes!("../../test-data/image/avatar1000x1000.jpg");
|
||||
tokio::fs::write(&file, bytes).await?;
|
||||
@@ -328,7 +305,7 @@ async fn test_no_sync_on_self_sent_msg() -> Result<()> {
|
||||
alice1
|
||||
.get_config(Config::Selfavatar)
|
||||
.await?
|
||||
.filter(|path| path.ends_with(".png"))
|
||||
.filter(|path| path.ends_with(".jpg"))
|
||||
.is_some()
|
||||
);
|
||||
sync(alice1, alice0).await;
|
||||
|
||||
215
src/configure.rs
215
src/configure.rs
@@ -23,11 +23,11 @@ use percent_encoding::utf8_percent_encode;
|
||||
use server_params::{ServerParams, expand_param_vector};
|
||||
use tokio::task;
|
||||
|
||||
use crate::config::{self, Config};
|
||||
use crate::config::Config;
|
||||
use crate::constants::NON_ALPHANUMERIC_WITHOUT_DOT;
|
||||
use crate::context::Context;
|
||||
use crate::imap::Imap;
|
||||
use crate::log::{LogExt, warn};
|
||||
use crate::log::warn;
|
||||
use crate::login_param::EnteredCertificateChecks;
|
||||
pub use crate::login_param::EnteredLoginParam;
|
||||
use crate::message::Message;
|
||||
@@ -40,11 +40,14 @@ use crate::sync::Sync::*;
|
||||
use crate::tools::time;
|
||||
use crate::transport::{
|
||||
ConfiguredCertificateChecks, ConfiguredLoginParam, ConfiguredServerLoginParam,
|
||||
ConnectionCandidate,
|
||||
ConnectionCandidate, send_sync_transports,
|
||||
};
|
||||
use crate::{EventType, stock_str};
|
||||
use crate::{chat, provider};
|
||||
use deltachat_contact_tools::addr_cmp;
|
||||
|
||||
/// Maximum number of relays
|
||||
/// see <https://github.com/chatmail/core/issues/7608>
|
||||
pub(crate) const MAX_TRANSPORT_RELAYS: usize = 5;
|
||||
|
||||
macro_rules! progress {
|
||||
($context:tt, $progress:expr, $comment:expr) => {
|
||||
@@ -130,12 +133,6 @@ impl Context {
|
||||
"cannot configure, database not opened."
|
||||
);
|
||||
param.addr = addr_normalize(¶m.addr);
|
||||
let old_addr = self.get_config(Config::ConfiguredAddr).await?;
|
||||
if self.is_configured().await? && !addr_cmp(&old_addr.unwrap_or_default(), ¶m.addr) {
|
||||
let error_msg = "Changing your email address is not supported right now. Check back in a few months!";
|
||||
progress!(self, 0, Some(error_msg.to_string()));
|
||||
bail!(error_msg);
|
||||
}
|
||||
let cancel_channel = self.alloc_ongoing().await?;
|
||||
|
||||
let res = self
|
||||
@@ -204,23 +201,109 @@ impl Context {
|
||||
Ok(transports)
|
||||
}
|
||||
|
||||
/// Returns the number of configured transports.
|
||||
pub async fn count_transports(&self) -> Result<usize> {
|
||||
self.sql.count("SELECT COUNT(*) FROM transports", ()).await
|
||||
}
|
||||
|
||||
/// Removes the transport with the specified email address
|
||||
/// (i.e. [EnteredLoginParam::addr]).
|
||||
#[expect(clippy::unused_async)]
|
||||
pub async fn delete_transport(&self, _addr: &str) -> Result<()> {
|
||||
bail!(
|
||||
"Adding and removing additional transports is not supported yet. Check back in a few months!"
|
||||
)
|
||||
pub async fn delete_transport(&self, addr: &str) -> Result<()> {
|
||||
let now = time();
|
||||
let removed_transport_id = self
|
||||
.sql
|
||||
.transaction(|transaction| {
|
||||
let primary_addr = transaction.query_row(
|
||||
"SELECT value FROM config WHERE keyname='configured_addr'",
|
||||
(),
|
||||
|row| {
|
||||
let addr: String = row.get(0)?;
|
||||
Ok(addr)
|
||||
},
|
||||
)?;
|
||||
|
||||
if primary_addr == addr {
|
||||
bail!("Cannot delete primary transport");
|
||||
}
|
||||
let (transport_id, add_timestamp) = transaction.query_row(
|
||||
"DELETE FROM transports WHERE addr=? RETURNING id, add_timestamp",
|
||||
(addr,),
|
||||
|row| {
|
||||
let id: u32 = row.get(0)?;
|
||||
let add_timestamp: i64 = row.get(1)?;
|
||||
Ok((id, add_timestamp))
|
||||
},
|
||||
)?;
|
||||
transaction.execute("DELETE FROM imap WHERE transport_id=?", (transport_id,))?;
|
||||
transaction.execute(
|
||||
"DELETE FROM imap_sync WHERE transport_id=?",
|
||||
(transport_id,),
|
||||
)?;
|
||||
|
||||
// Removal timestamp should not be lower than addition timestamp
|
||||
// to be accepted by other devices when synced.
|
||||
let remove_timestamp = std::cmp::max(now, add_timestamp);
|
||||
|
||||
transaction.execute(
|
||||
"INSERT INTO removed_transports (addr, remove_timestamp)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT (addr)
|
||||
DO UPDATE SET remove_timestamp = excluded.remove_timestamp",
|
||||
(addr, remove_timestamp),
|
||||
)?;
|
||||
|
||||
Ok(transport_id)
|
||||
})
|
||||
.await?;
|
||||
send_sync_transports(self).await?;
|
||||
self.quota.write().await.remove(&removed_transport_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn inner_configure(&self, param: &EnteredLoginParam) -> Result<()> {
|
||||
info!(self, "Configure ...");
|
||||
|
||||
let old_addr = self.get_config(Config::ConfiguredAddr).await?;
|
||||
let provider = configure(self, param).await?;
|
||||
if old_addr.is_some()
|
||||
&& !self
|
||||
.sql
|
||||
.exists(
|
||||
"SELECT COUNT(*) FROM transports WHERE addr=?",
|
||||
(¶m.addr,),
|
||||
)
|
||||
.await?
|
||||
&& self
|
||||
.sql
|
||||
.count("SELECT COUNT(*) FROM transports", ())
|
||||
.await?
|
||||
>= MAX_TRANSPORT_RELAYS
|
||||
{
|
||||
bail!(
|
||||
"You have reached the maximum number of relays ({}).",
|
||||
MAX_TRANSPORT_RELAYS
|
||||
)
|
||||
}
|
||||
|
||||
let provider = match configure(self, param).await {
|
||||
Err(error) => {
|
||||
// Log entered and actual params
|
||||
let configured_param = get_configured_param(self, param).await;
|
||||
warn!(
|
||||
self,
|
||||
"configure failed: Entered params: {}. Used params: {}. Error: {error}.",
|
||||
param.to_string(),
|
||||
configured_param
|
||||
.map(|param| param.to_string())
|
||||
.unwrap_or("error".to_owned())
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(provider) => provider,
|
||||
};
|
||||
self.set_config_internal(Config::NotifyAboutWrongPw, Some("1"))
|
||||
.await?;
|
||||
on_configure_completed(self, provider, old_addr).await?;
|
||||
on_configure_completed(self, provider).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -228,7 +311,6 @@ impl Context {
|
||||
async fn on_configure_completed(
|
||||
context: &Context,
|
||||
provider: Option<&'static Provider>,
|
||||
old_addr: Option<String>,
|
||||
) -> Result<()> {
|
||||
if let Some(provider) = provider {
|
||||
if let Some(config_defaults) = provider.config_defaults {
|
||||
@@ -258,21 +340,6 @@ async fn on_configure_completed(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(new_addr) = context.get_config(Config::ConfiguredAddr).await? {
|
||||
if let Some(old_addr) = old_addr {
|
||||
if !addr_cmp(&new_addr, &old_addr) {
|
||||
let mut msg = Message::new_text(
|
||||
stock_str::aeap_explanation_and_link(context, &old_addr, &new_addr).await,
|
||||
);
|
||||
chat::add_device_msg(context, None, Some(&mut msg))
|
||||
.await
|
||||
.context("Cannot add AEAP explanation")
|
||||
.log_err(context)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -428,6 +495,7 @@ async fn get_configured_param(
|
||||
.collect(),
|
||||
imap_user: param.imap.user.clone(),
|
||||
imap_password: param.imap.password.clone(),
|
||||
imap_folder: Some(param.imap.folder.clone()).filter(|folder| !folder.is_empty()),
|
||||
smtp: servers
|
||||
.iter()
|
||||
.filter_map(|params| {
|
||||
@@ -467,9 +535,6 @@ async fn get_configured_param(
|
||||
async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'static Provider>> {
|
||||
progress!(ctx, 1);
|
||||
|
||||
let ctx2 = ctx.clone();
|
||||
let update_device_chats_handle = task::spawn(async move { ctx2.update_device_chats().await });
|
||||
|
||||
let configured_param = get_configured_param(ctx, param).await?;
|
||||
let proxy_config = ProxyConfig::load(ctx).await?;
|
||||
let strict_tls = configured_param.strict_tls(proxy_config.is_some());
|
||||
@@ -504,83 +569,61 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
|
||||
|
||||
// Configure IMAP
|
||||
|
||||
let transport_id = 0;
|
||||
let (_s, r) = async_channel::bounded(1);
|
||||
let mut imap = Imap::new(
|
||||
configured_param.imap.clone(),
|
||||
configured_param.imap_password.clone(),
|
||||
proxy_config,
|
||||
&configured_param.addr,
|
||||
strict_tls,
|
||||
configured_param.oauth2,
|
||||
r,
|
||||
);
|
||||
let mut imap = Imap::new(ctx, transport_id, configured_param.clone(), r).await?;
|
||||
let configuring = true;
|
||||
let mut imap_session = match imap.connect(ctx, configuring).await {
|
||||
Ok(session) => session,
|
||||
Err(err) => bail!(
|
||||
"{}",
|
||||
nicer_configuration_error(ctx, format!("{err:#}")).await
|
||||
),
|
||||
let imap_session = match imap.connect(ctx, configuring).await {
|
||||
Ok(imap_session) => imap_session,
|
||||
Err(err) => {
|
||||
bail!(
|
||||
"{}",
|
||||
nicer_configuration_error(ctx, format!("{err:#}")).await
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
progress!(ctx, 850);
|
||||
|
||||
// Wait for SMTP configuration
|
||||
smtp_config_task.await.unwrap()?;
|
||||
smtp_config_task.await??;
|
||||
|
||||
progress!(ctx, 900);
|
||||
|
||||
let is_chatmail = match ctx.get_config_bool(Config::FixIsChatmail).await? {
|
||||
false => {
|
||||
let is_chatmail = imap_session.is_chatmail();
|
||||
ctx.set_config(
|
||||
Config::IsChatmail,
|
||||
Some(match is_chatmail {
|
||||
false => "0",
|
||||
true => "1",
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
is_chatmail
|
||||
let is_configured = ctx.is_configured().await?;
|
||||
if !ctx.get_config_bool(Config::FixIsChatmail).await? {
|
||||
if imap_session.is_chatmail() {
|
||||
ctx.sql.set_raw_config("is_chatmail", Some("1")).await?;
|
||||
} else if !is_configured {
|
||||
// Reset the setting that may have been set
|
||||
// during failed configuration.
|
||||
ctx.sql.set_raw_config("is_chatmail", Some("0")).await?;
|
||||
}
|
||||
true => ctx.get_config_bool(Config::IsChatmail).await?,
|
||||
};
|
||||
if is_chatmail {
|
||||
ctx.set_config(Config::MvboxMove, Some("0")).await?;
|
||||
ctx.set_config(Config::OnlyFetchMvbox, None).await?;
|
||||
ctx.set_config(Config::ShowEmails, None).await?;
|
||||
}
|
||||
|
||||
let create_mvbox = !is_chatmail;
|
||||
imap.configure_folders(ctx, &mut imap_session, create_mvbox)
|
||||
.await?;
|
||||
|
||||
let create = true;
|
||||
imap_session
|
||||
.select_with_uidvalidity(ctx, "INBOX", create)
|
||||
.await
|
||||
.context("could not read INBOX status")?;
|
||||
|
||||
drop(imap_session);
|
||||
drop(imap);
|
||||
|
||||
progress!(ctx, 910);
|
||||
|
||||
let provider = configured_param.provider;
|
||||
configured_param
|
||||
.save_to_transports_table(ctx, param)
|
||||
.clone()
|
||||
.save_to_transports_table(ctx, param, time())
|
||||
.await?;
|
||||
send_sync_transports(ctx).await?;
|
||||
|
||||
ctx.set_config_internal(Config::ConfiguredTimestamp, Some(&time().to_string()))
|
||||
.await?;
|
||||
|
||||
progress!(ctx, 920);
|
||||
|
||||
ctx.set_config_internal(Config::FetchedExistingMsgs, config::from_bool(false))
|
||||
.await?;
|
||||
ctx.scheduler.interrupt_inbox().await;
|
||||
|
||||
progress!(ctx, 940);
|
||||
update_device_chats_handle.await??;
|
||||
ctx.update_device_chats()
|
||||
.await
|
||||
.context("Failed to update device chats")?;
|
||||
|
||||
ctx.sql.set_raw_config_bool("configured", true).await?;
|
||||
ctx.emit_event(EventType::AccountsItemChanged);
|
||||
@@ -711,7 +754,7 @@ pub enum Error {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::login_param::EnteredServerLoginParam;
|
||||
use crate::login_param::EnteredImapLoginParam;
|
||||
use crate::test_utils::TestContext;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -730,7 +773,7 @@ mod tests {
|
||||
let entered_param = EnteredLoginParam {
|
||||
addr: "alice@example.org".to_string(),
|
||||
|
||||
imap: EnteredServerLoginParam {
|
||||
imap: EnteredImapLoginParam {
|
||||
user: "alice@example.net".to_string(),
|
||||
password: "foobar".to_string(),
|
||||
..Default::default()
|
||||
|
||||
@@ -154,10 +154,10 @@ fn parse_xml_reader<B: BufRead>(
|
||||
if let Some(incoming_server) = parse_server(reader, event)? {
|
||||
incoming_servers.push(incoming_server);
|
||||
}
|
||||
} else if tag == "outgoingserver" {
|
||||
if let Some(outgoing_server) = parse_server(reader, event)? {
|
||||
outgoing_servers.push(outgoing_server);
|
||||
}
|
||||
} else if tag == "outgoingserver"
|
||||
&& let Some(outgoing_server) = parse_server(reader, event)?
|
||||
{
|
||||
outgoing_servers.push(outgoing_server);
|
||||
}
|
||||
}
|
||||
Event::Eof => break,
|
||||
|
||||
@@ -2,16 +2,13 @@
|
||||
|
||||
#![allow(missing_docs)]
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use deltachat_derive::{FromSql, ToSql};
|
||||
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::chat::ChatId;
|
||||
|
||||
pub static DC_VERSION_STR: LazyLock<String> =
|
||||
LazyLock::new(|| env!("CARGO_PKG_VERSION").to_string());
|
||||
pub static DC_VERSION_STR: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Set of characters to percent-encode in email addresses and names.
|
||||
pub(crate) const NON_ALPHANUMERIC_WITHOUT_DOT: &AsciiSet = &NON_ALPHANUMERIC.remove(b'.');
|
||||
@@ -213,11 +210,6 @@ pub const WORSE_IMAGE_SIZE: u32 = 640;
|
||||
/// usage by UIs.
|
||||
pub const MAX_RCVD_IMAGE_PIXELS: u32 = 50_000_000;
|
||||
|
||||
// Key for the folder configuration version (see below).
|
||||
pub(crate) const DC_FOLDERS_CONFIGURED_KEY: &str = "folders_configured";
|
||||
// this value can be increased if the folder configuration is changed and must be redone on next program start
|
||||
pub(crate) const DC_FOLDERS_CONFIGURED_VERSION: i32 = 5;
|
||||
|
||||
// If more recipients are needed in SMTP's `RCPT TO:` header, the recipient list is split into
|
||||
// chunks. This does not affect MIME's `To:` header. Can be overwritten by setting
|
||||
// `max_smtp_rcpt_to` in the provider db.
|
||||
|
||||
370
src/contact.rs
370
src/contact.rs
@@ -35,8 +35,9 @@ use crate::log::{LogExt, warn};
|
||||
use crate::message::MessageState;
|
||||
use crate::mimeparser::AvatarAction;
|
||||
use crate::param::{Param, Params};
|
||||
use crate::pgp::{addresses_from_public_key, merge_openpgp_certificates};
|
||||
use crate::sync::{self, Sync::*};
|
||||
use crate::tools::{SystemTime, duration_to_str, get_abs_path, time, to_lowercase};
|
||||
use crate::tools::{SystemTime, duration_to_str, get_abs_path, normalize_text, time, to_lowercase};
|
||||
use crate::{chat, chatlist_events, ensure_and_debug_assert_ne, stock_str};
|
||||
|
||||
/// Time during which a contact is considered as seen recently.
|
||||
@@ -115,9 +116,23 @@ impl ContactId {
|
||||
let row = context
|
||||
.sql
|
||||
.transaction(|transaction| {
|
||||
let authname;
|
||||
let name_or_authname = if !name.is_empty() {
|
||||
name
|
||||
} else {
|
||||
authname = transaction.query_row(
|
||||
"SELECT authname FROM contacts WHERE id=?",
|
||||
(self,),
|
||||
|row| {
|
||||
let authname: String = row.get(0)?;
|
||||
Ok(authname)
|
||||
},
|
||||
)?;
|
||||
&authname
|
||||
};
|
||||
let is_changed = transaction.execute(
|
||||
"UPDATE contacts SET name=?1 WHERE id=?2 AND name!=?1",
|
||||
(name, self),
|
||||
"UPDATE contacts SET name=?1, name_normalized=?2 WHERE id=?3 AND name!=?1",
|
||||
(name, normalize_text(name_or_authname), self),
|
||||
)? > 0;
|
||||
if is_changed {
|
||||
update_chat_names(context, transaction, self)?;
|
||||
@@ -130,35 +145,37 @@ impl ContactId {
|
||||
Ok((addr, fingerprint))
|
||||
},
|
||||
)?;
|
||||
context.emit_event(EventType::ContactsChanged(Some(self)));
|
||||
Ok(Some((addr, fingerprint)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
if row.is_some() {
|
||||
context.emit_event(EventType::ContactsChanged(Some(self)));
|
||||
}
|
||||
|
||||
if sync.into() {
|
||||
if let Some((addr, fingerprint)) = row {
|
||||
if fingerprint.is_empty() {
|
||||
chat::sync(
|
||||
context,
|
||||
chat::SyncId::ContactAddr(addr),
|
||||
chat::SyncAction::Rename(name.to_string()),
|
||||
)
|
||||
.await
|
||||
.log_err(context)
|
||||
.ok();
|
||||
} else {
|
||||
chat::sync(
|
||||
context,
|
||||
chat::SyncId::ContactFingerprint(fingerprint),
|
||||
chat::SyncAction::Rename(name.to_string()),
|
||||
)
|
||||
.await
|
||||
.log_err(context)
|
||||
.ok();
|
||||
}
|
||||
if sync.into()
|
||||
&& let Some((addr, fingerprint)) = row
|
||||
{
|
||||
if fingerprint.is_empty() {
|
||||
chat::sync(
|
||||
context,
|
||||
chat::SyncId::ContactAddr(addr),
|
||||
chat::SyncAction::Rename(name.to_string()),
|
||||
)
|
||||
.await
|
||||
.log_err(context)
|
||||
.ok();
|
||||
} else {
|
||||
chat::sync(
|
||||
context,
|
||||
chat::SyncId::ContactFingerprint(fingerprint),
|
||||
chat::SyncAction::Rename(name.to_string()),
|
||||
)
|
||||
.await
|
||||
.log_err(context)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -298,6 +315,67 @@ pub async fn make_vcard(context: &Context, contacts: &[ContactId]) -> Result<Str
|
||||
.to_string())
|
||||
}
|
||||
|
||||
/// Imports public key into the public key store.
|
||||
///
|
||||
/// They key may come from Autocrypt header,
|
||||
/// Autocrypt-Gossip header or a vCard.
|
||||
///
|
||||
/// If the key with the same fingerprint already exists,
|
||||
/// it is updated by merging the new key.
|
||||
pub(crate) async fn import_public_key(
|
||||
context: &Context,
|
||||
public_key: &SignedPublicKey,
|
||||
) -> Result<()> {
|
||||
public_key
|
||||
.verify_bindings()
|
||||
.context("Attempt to import broken public key")?;
|
||||
|
||||
let fingerprint = public_key.dc_fingerprint().hex();
|
||||
|
||||
let merged_public_key;
|
||||
let merged_public_key_ref = if let Some(public_key_bytes) = context
|
||||
.sql
|
||||
.query_row_optional(
|
||||
"SELECT public_key
|
||||
FROM public_keys
|
||||
WHERE fingerprint=?",
|
||||
(&fingerprint,),
|
||||
|row| {
|
||||
let bytes: Vec<u8> = row.get(0)?;
|
||||
Ok(bytes)
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let old_public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
|
||||
merged_public_key = merge_openpgp_certificates(public_key.clone(), old_public_key)
|
||||
.context("Failed to merge public keys")?;
|
||||
&merged_public_key
|
||||
} else {
|
||||
public_key
|
||||
};
|
||||
|
||||
let inserted = context
|
||||
.sql
|
||||
.execute(
|
||||
"INSERT INTO public_keys (fingerprint, public_key)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT (fingerprint)
|
||||
DO UPDATE SET public_key=excluded.public_key
|
||||
WHERE public_key!=excluded.public_key",
|
||||
(&fingerprint, merged_public_key_ref.to_bytes()),
|
||||
)
|
||||
.await?;
|
||||
if inserted > 0 {
|
||||
info!(
|
||||
context,
|
||||
"Saved key with fingerprint {fingerprint} from the Autocrypt header"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Imports contacts from the given vCard.
|
||||
///
|
||||
/// Returns the ids of successfully processed contacts in the order they appear in `vcard`,
|
||||
@@ -336,23 +414,14 @@ async fn import_vcard_contact(context: &Context, contact: &VcardContact) -> Resu
|
||||
.ok()
|
||||
});
|
||||
|
||||
let fingerprint;
|
||||
if let Some(public_key) = key {
|
||||
fingerprint = public_key.dc_fingerprint().hex();
|
||||
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"INSERT INTO public_keys (fingerprint, public_key)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT (fingerprint)
|
||||
DO NOTHING",
|
||||
(&fingerprint, public_key.to_bytes()),
|
||||
)
|
||||
.await?;
|
||||
let fingerprint = if let Some(public_key) = key {
|
||||
import_public_key(context, &public_key)
|
||||
.await
|
||||
.context("Failed to import public key from vCard")?;
|
||||
public_key.dc_fingerprint().hex()
|
||||
} else {
|
||||
fingerprint = String::new();
|
||||
}
|
||||
String::new()
|
||||
};
|
||||
|
||||
let (id, modified) =
|
||||
match Contact::add_or_lookup_ex(context, &contact.authname, &addr, &fingerprint, origin)
|
||||
@@ -381,25 +450,21 @@ async fn import_vcard_contact(context: &Context, contact: &VcardContact) -> Resu
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
if let Some(path) = path {
|
||||
// Currently this value doesn't matter as we don't import the contact of self.
|
||||
let was_encrypted = false;
|
||||
if let Err(e) =
|
||||
set_profile_image(context, id, &AvatarAction::Change(path), was_encrypted).await
|
||||
{
|
||||
warn!(
|
||||
context,
|
||||
"import_vcard_contact: Could not set avatar for {}: {e:#}.", contact.addr
|
||||
);
|
||||
}
|
||||
if let Some(path) = path
|
||||
&& let Err(e) = set_profile_image(context, id, &AvatarAction::Change(path)).await
|
||||
{
|
||||
warn!(
|
||||
context,
|
||||
"import_vcard_contact: Could not set avatar for {}: {e:#}.", contact.addr
|
||||
);
|
||||
}
|
||||
if let Some(biography) = &contact.biography {
|
||||
if let Err(e) = set_status(context, id, biography.to_owned(), false, false).await {
|
||||
warn!(
|
||||
context,
|
||||
"import_vcard_contact: Could not set biography for {}: {e:#}.", contact.addr
|
||||
);
|
||||
}
|
||||
if let Some(biography) = &contact.biography
|
||||
&& let Err(e) = set_status(context, id, biography.to_owned()).await
|
||||
{
|
||||
warn!(
|
||||
context,
|
||||
"import_vcard_contact: Could not set biography for {}: {e:#}.", contact.addr
|
||||
);
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
@@ -661,6 +726,7 @@ impl Contact {
|
||||
}
|
||||
|
||||
/// Returns `true` if this contact was seen recently.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub fn was_seen_recently(&self) -> bool {
|
||||
time() - self.last_seen <= SEEN_RECENTLY_SECONDS
|
||||
}
|
||||
@@ -969,11 +1035,22 @@ impl Contact {
|
||||
} else {
|
||||
row_name
|
||||
};
|
||||
let new_authname = if update_authname {
|
||||
name.to_string()
|
||||
} else {
|
||||
row_authname
|
||||
};
|
||||
|
||||
transaction.execute(
|
||||
"UPDATE contacts SET name=?, addr=?, origin=?, authname=? WHERE id=?;",
|
||||
"UPDATE contacts SET name=?, name_normalized=?, addr=?, origin=?, authname=? WHERE id=?",
|
||||
(
|
||||
new_name,
|
||||
&new_name,
|
||||
normalize_text(
|
||||
if !new_name.is_empty() {
|
||||
&new_name
|
||||
} else {
|
||||
&new_authname
|
||||
}),
|
||||
if update_addr {
|
||||
addr.to_string()
|
||||
} else {
|
||||
@@ -984,11 +1061,7 @@ impl Contact {
|
||||
} else {
|
||||
row_origin
|
||||
},
|
||||
if update_authname {
|
||||
name.to_string()
|
||||
} else {
|
||||
row_authname
|
||||
},
|
||||
&new_authname,
|
||||
row_id,
|
||||
),
|
||||
)?;
|
||||
@@ -1000,18 +1073,18 @@ impl Contact {
|
||||
sth_modified = Modifier::Modified;
|
||||
}
|
||||
} else {
|
||||
let update_name = manual;
|
||||
let update_authname = !manual;
|
||||
|
||||
transaction.execute(
|
||||
"INSERT INTO contacts (name, addr, fingerprint, origin, authname)
|
||||
VALUES (?, ?, ?, ?, ?);",
|
||||
"
|
||||
INSERT INTO contacts (name, name_normalized, addr, fingerprint, origin, authname)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
",
|
||||
(
|
||||
if update_name { &name } else { "" },
|
||||
if manual { &name } else { "" },
|
||||
normalize_text(&name),
|
||||
&addr,
|
||||
fingerprint,
|
||||
origin,
|
||||
if update_authname { &name } else { "" },
|
||||
if manual { "" } else { &name },
|
||||
),
|
||||
)?;
|
||||
|
||||
@@ -1052,6 +1125,7 @@ impl Contact {
|
||||
/// The `addr_book` is a multiline string in the format `Name one\nAddress one\nName two\nAddress two`.
|
||||
///
|
||||
/// Returns the number of modified contacts.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub async fn add_address_book(context: &Context, addr_book: &str) -> Result<usize> {
|
||||
let mut modify_cnt = 0;
|
||||
|
||||
@@ -1114,23 +1188,27 @@ impl Contact {
|
||||
Origin::IncomingReplyTo
|
||||
};
|
||||
if query.is_some() {
|
||||
let s3str_like_cmd = format!("%{}%", query.unwrap_or(""));
|
||||
let query_lowercased = query.unwrap_or("").to_lowercase();
|
||||
let s3str_like_cmd = format!("%{}%", query_lowercased);
|
||||
context
|
||||
.sql
|
||||
.query_map(
|
||||
"SELECT c.id, c.addr FROM contacts c
|
||||
WHERE c.id>?
|
||||
AND (c.fingerprint='')=?
|
||||
AND c.origin>=? \
|
||||
AND c.blocked=0 \
|
||||
AND (iif(c.name='',c.authname,c.name) LIKE ? OR c.addr LIKE ?) \
|
||||
ORDER BY c.last_seen DESC, c.id DESC;",
|
||||
"
|
||||
SELECT c.id, c.addr FROM contacts c
|
||||
WHERE c.id>?
|
||||
AND (c.fingerprint='')=?
|
||||
AND c.origin>=?
|
||||
AND c.blocked=0
|
||||
AND (IFNULL(c.name_normalized,IIF(c.name='',c.authname,c.name)) LIKE ? OR c.addr LIKE ?)
|
||||
ORDER BY c.origin>=? DESC, c.last_seen DESC, c.id DESC
|
||||
",
|
||||
(
|
||||
ContactId::LAST_SPECIAL,
|
||||
flag_address,
|
||||
minimal_origin,
|
||||
&s3str_like_cmd,
|
||||
&s3str_like_cmd,
|
||||
&query_lowercased,
|
||||
Origin::CreateChat,
|
||||
),
|
||||
|row| {
|
||||
let id: ContactId = row.get(0)?;
|
||||
@@ -1180,8 +1258,13 @@ impl Contact {
|
||||
AND (fingerprint='')=?
|
||||
AND origin>=?
|
||||
AND blocked=0
|
||||
ORDER BY last_seen DESC, id DESC;",
|
||||
(ContactId::LAST_SPECIAL, flag_address, minimal_origin),
|
||||
ORDER BY origin>=? DESC, last_seen DESC, id DESC",
|
||||
(
|
||||
ContactId::LAST_SPECIAL,
|
||||
flag_address,
|
||||
minimal_origin,
|
||||
Origin::CreateChat,
|
||||
),
|
||||
|row| {
|
||||
let id: ContactId = row.get(0)?;
|
||||
let addr: String = row.get(1)?;
|
||||
@@ -1251,8 +1334,18 @@ impl Contact {
|
||||
};
|
||||
// Always do an update in case the blocking is reset or name is changed.
|
||||
transaction.execute(
|
||||
"UPDATE contacts SET name=?, origin=?, blocked=1, fingerprint=? WHERE addr=?",
|
||||
(&name, Origin::MailinglistAddress, fingerprint, &grpid),
|
||||
"
|
||||
UPDATE contacts
|
||||
SET name=?, name_normalized=IIF(?1='',name_normalized,?), origin=?, blocked=1, fingerprint=?
|
||||
WHERE addr=?
|
||||
",
|
||||
(
|
||||
&name,
|
||||
normalize_text(&name),
|
||||
Origin::MailinglistAddress,
|
||||
fingerprint,
|
||||
&grpid,
|
||||
),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -1261,18 +1354,6 @@ impl Contact {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns number of blocked contacts.
|
||||
pub async fn get_blocked_cnt(context: &Context) -> Result<usize> {
|
||||
let count = context
|
||||
.sql
|
||||
.count(
|
||||
"SELECT COUNT(*) FROM contacts WHERE id>? AND blocked!=0",
|
||||
(ContactId::LAST_SPECIAL,),
|
||||
)
|
||||
.await?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Get blocked contacts.
|
||||
pub async fn get_all_blocked(context: &Context) -> Result<Vec<ContactId>> {
|
||||
Contact::update_blocked_mailinglist_contacts(context)
|
||||
@@ -1316,13 +1397,13 @@ impl Contact {
|
||||
let fingerprint_other = fingerprint_other.to_string();
|
||||
|
||||
let stock_message = if contact.public_key(context).await?.is_some() {
|
||||
stock_str::e2e_available(context).await
|
||||
stock_str::messages_are_e2ee(context).await
|
||||
} else {
|
||||
stock_str::encr_none(context).await
|
||||
};
|
||||
|
||||
let finger_prints = stock_str::finger_prints(context).await;
|
||||
let mut ret = format!("{stock_message}.\n{finger_prints}:");
|
||||
let mut ret = format!("{stock_message}\n{finger_prints}:");
|
||||
|
||||
let fingerprint_self = load_self_public_key(context)
|
||||
.await?
|
||||
@@ -1356,6 +1437,16 @@ impl Contact {
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(public_key) = contact.public_key(context).await?
|
||||
&& let Some(relay_addrs) = addresses_from_public_key(&public_key)
|
||||
{
|
||||
ret += "\n\nRelays:";
|
||||
for relay in &relay_addrs {
|
||||
ret += "\n";
|
||||
ret += relay;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
@@ -1428,7 +1519,7 @@ impl Contact {
|
||||
/// Returns true if the contact is a key-contact.
|
||||
/// Otherwise it is an addresss-contact.
|
||||
pub fn is_key_contact(&self) -> bool {
|
||||
self.fingerprint.is_some()
|
||||
self.fingerprint.is_some() || self.id == ContactId::SELF
|
||||
}
|
||||
|
||||
/// Returns OpenPGP fingerprint of a contact.
|
||||
@@ -1507,18 +1598,6 @@ impl Contact {
|
||||
&self.addr
|
||||
}
|
||||
|
||||
/// Get authorized name or address.
|
||||
///
|
||||
/// This string is suitable for sending over email
|
||||
/// as it does not leak the locally set name.
|
||||
pub(crate) fn get_authname_or_addr(&self) -> String {
|
||||
if !self.authname.is_empty() {
|
||||
(&self.authname).into()
|
||||
} else {
|
||||
(&self.addr).into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a summary of name and address.
|
||||
///
|
||||
/// The returned string is either "Name (email@domain.com)" or just
|
||||
@@ -1564,10 +1643,10 @@ impl Contact {
|
||||
if show_fallback_icon && !self.id.is_special() && !self.is_key_contact() {
|
||||
return Ok(Some(chat::get_unencrypted_icon(context).await?));
|
||||
}
|
||||
if let Some(image_rel) = self.param.get(Param::ProfileImage) {
|
||||
if !image_rel.is_empty() {
|
||||
return Ok(Some(get_abs_path(context, Path::new(image_rel))));
|
||||
}
|
||||
if let Some(image_rel) = self.param.get(Param::ProfileImage)
|
||||
&& !image_rel.is_empty()
|
||||
{
|
||||
return Ok(Some(get_abs_path(context, Path::new(image_rel))));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
@@ -1633,8 +1712,7 @@ impl Contact {
|
||||
///
|
||||
/// If this returns Some(_),
|
||||
/// display green checkmark in the profile and "Introduced by ..." line
|
||||
/// with the name and address of the contact
|
||||
/// formatted by [Self::get_name_n_addr].
|
||||
/// with the name of the contact.
|
||||
///
|
||||
/// If this returns `Some(None)`, then the contact is verified,
|
||||
/// but it's unclear by whom.
|
||||
@@ -1739,8 +1817,8 @@ fn update_chat_names(
|
||||
};
|
||||
|
||||
let count = transaction.execute(
|
||||
"UPDATE chats SET name=?1 WHERE id=?2 AND name!=?1",
|
||||
(chat_name, chat_id),
|
||||
"UPDATE chats SET name=?1, name_normalized=?2 WHERE id=?3 AND name!=?1",
|
||||
(&chat_name, normalize_text(&chat_name), chat_id),
|
||||
)?;
|
||||
|
||||
if count > 0 {
|
||||
@@ -1800,10 +1878,11 @@ WHERE type=? AND id IN (
|
||||
|
||||
// also unblock mailinglist
|
||||
// if the contact is a mailinglist address explicitly created to allow unblocking
|
||||
if !new_blocking && contact.origin == Origin::MailinglistAddress {
|
||||
if let Some((chat_id, ..)) = chat::get_chat_id_by_grpid(context, &contact.addr).await? {
|
||||
chat_id.unblock_ex(context, Nosync).await?;
|
||||
}
|
||||
if !new_blocking
|
||||
&& contact.origin == Origin::MailinglistAddress
|
||||
&& let Some((chat_id, ..)) = chat::get_chat_id_by_grpid(context, &contact.addr).await?
|
||||
{
|
||||
chat_id.unblock_ex(context, Nosync).await?;
|
||||
}
|
||||
|
||||
if sync.into() {
|
||||
@@ -1833,25 +1912,19 @@ WHERE type=? AND id IN (
|
||||
/// The given profile image is expected to be already in the blob directory
|
||||
/// as profile images can be set only by receiving messages, this should be always the case, however.
|
||||
///
|
||||
/// For contact SELF, the image is not saved in the contact-database but as Config::Selfavatar;
|
||||
/// this typically happens if we see message with our own profile image.
|
||||
/// For contact SELF, the image is not saved in the contact-database but as Config::Selfavatar.
|
||||
pub(crate) async fn set_profile_image(
|
||||
context: &Context,
|
||||
contact_id: ContactId,
|
||||
profile_image: &AvatarAction,
|
||||
was_encrypted: bool,
|
||||
) -> Result<()> {
|
||||
let mut contact = Contact::get_by_id(context, contact_id).await?;
|
||||
let changed = match profile_image {
|
||||
AvatarAction::Change(profile_image) => {
|
||||
if contact_id == ContactId::SELF {
|
||||
if was_encrypted {
|
||||
context
|
||||
.set_config_ex(Nosync, Config::Selfavatar, Some(profile_image))
|
||||
.await?;
|
||||
} else {
|
||||
info!(context, "Do not use unencrypted selfavatar.");
|
||||
}
|
||||
context
|
||||
.set_config_ex(Nosync, Config::Selfavatar, Some(profile_image))
|
||||
.await?;
|
||||
} else {
|
||||
contact.param.set(Param::ProfileImage, profile_image);
|
||||
}
|
||||
@@ -1859,13 +1932,9 @@ pub(crate) async fn set_profile_image(
|
||||
}
|
||||
AvatarAction::Delete => {
|
||||
if contact_id == ContactId::SELF {
|
||||
if was_encrypted {
|
||||
context
|
||||
.set_config_ex(Nosync, Config::Selfavatar, None)
|
||||
.await?;
|
||||
} else {
|
||||
info!(context, "Do not use unencrypted selfavatar deletion.");
|
||||
}
|
||||
context
|
||||
.set_config_ex(Nosync, Config::Selfavatar, None)
|
||||
.await?;
|
||||
} else {
|
||||
contact.param.remove(Param::ProfileImage);
|
||||
}
|
||||
@@ -1882,22 +1951,16 @@ pub(crate) async fn set_profile_image(
|
||||
|
||||
/// Sets contact status.
|
||||
///
|
||||
/// For contact SELF, the status is not saved in the contact table, but as Config::Selfstatus. This
|
||||
/// is only done if message is sent from Delta Chat and it is encrypted, to synchronize signature
|
||||
/// between Delta Chat devices.
|
||||
/// For contact SELF, the status is not saved in the contact table, but as Config::Selfstatus.
|
||||
pub(crate) async fn set_status(
|
||||
context: &Context,
|
||||
contact_id: ContactId,
|
||||
status: String,
|
||||
encrypted: bool,
|
||||
has_chat_version: bool,
|
||||
) -> Result<()> {
|
||||
if contact_id == ContactId::SELF {
|
||||
if encrypted && has_chat_version {
|
||||
context
|
||||
.set_config_ex(Nosync, Config::Selfstatus, Some(&status))
|
||||
.await?;
|
||||
}
|
||||
context
|
||||
.set_config_ex(Nosync, Config::Selfstatus, Some(&status))
|
||||
.await?;
|
||||
} else {
|
||||
let mut contact = Contact::get_by_id(context, contact_id).await?;
|
||||
|
||||
@@ -1911,6 +1974,7 @@ pub(crate) async fn set_status(
|
||||
}
|
||||
|
||||
/// Updates last seen timestamp of the contact if it is earlier than the given `timestamp`.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub(crate) async fn update_last_seen(
|
||||
context: &Context,
|
||||
contact_id: ContactId,
|
||||
@@ -2002,6 +2066,7 @@ pub(crate) async fn mark_contact_id_as_verified(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn cat_fingerprint(ret: &mut String, name: &str, addr: &str, fingerprint: &str) {
|
||||
*ret += &format!("\n\n{name} ({addr}):\n{fingerprint}");
|
||||
}
|
||||
@@ -2043,6 +2108,7 @@ impl RecentlySeenLoop {
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn run(context: Context, interrupt: Receiver<RecentlySeenInterrupt>) {
|
||||
type MyHeapElem = (Reverse<i64>, ContactId);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::*;
|
||||
use crate::chat::{Chat, get_chat_contacts, send_text_msg};
|
||||
use crate::chatlist::Chatlist;
|
||||
use crate::receive_imf::receive_imf;
|
||||
use crate::test_utils::{self, TestContext, TestContextManager, TimeShiftFalsePositiveNote};
|
||||
use crate::test_utils::{self, TestContext, TestContextManager, TimeShiftFalsePositiveNote, sync};
|
||||
|
||||
#[test]
|
||||
fn test_contact_id_values() {
|
||||
@@ -60,16 +60,16 @@ async fn test_get_contacts() -> Result<()> {
|
||||
let context = tcm.bob().await;
|
||||
let alice = tcm.alice().await;
|
||||
alice
|
||||
.set_config(Config::Displayname, Some("MyName"))
|
||||
.set_config(Config::Displayname, Some("MyNameIsΔ"))
|
||||
.await?;
|
||||
|
||||
// Alice is not in the contacts yet.
|
||||
let contacts = Contact::get_all(&context.ctx, 0, Some("Alice")).await?;
|
||||
assert_eq!(contacts.len(), 0);
|
||||
let contacts = Contact::get_all(&context.ctx, 0, Some("MyName")).await?;
|
||||
let contacts = Contact::get_all(&context.ctx, 0, Some("MyNameIsΔ")).await?;
|
||||
assert_eq!(contacts.len(), 0);
|
||||
|
||||
let claire_id = Contact::create(&context, "someone", "claire@example.org").await?;
|
||||
let claire_id = Contact::create(&context, "Δ-someone", "claire@example.org").await?;
|
||||
let dave_id = Contact::create(&context, "", "dave@example.org").await?;
|
||||
|
||||
let id = context.add_or_lookup_contact_id(&alice).await;
|
||||
@@ -77,28 +77,33 @@ async fn test_get_contacts() -> Result<()> {
|
||||
|
||||
let contact = Contact::get_by_id(&context, id).await.unwrap();
|
||||
assert_eq!(contact.get_name(), "");
|
||||
assert_eq!(contact.get_authname(), "MyName");
|
||||
assert_eq!(contact.get_display_name(), "MyName");
|
||||
assert_eq!(contact.get_authname(), "MyNameIsΔ");
|
||||
assert_eq!(contact.get_display_name(), "MyNameIsΔ");
|
||||
|
||||
// Search by name.
|
||||
let contacts = Contact::get_all(&context, 0, Some("myname")).await?;
|
||||
assert_eq!(contacts.len(), 1);
|
||||
assert_eq!(contacts.first(), Some(&id));
|
||||
|
||||
// Search by address.
|
||||
// Search by address is case-insensitive, but only returns direct matches.
|
||||
let contacts = Contact::get_all(&context, 0, Some("alice@example.org")).await?;
|
||||
assert_eq!(contacts.len(), 1);
|
||||
assert_eq!(contacts.first(), Some(&id));
|
||||
let contacts = Contact::get_all(&context, 0, Some("Alice@example.org")).await?;
|
||||
assert_eq!(contacts.len(), 1);
|
||||
assert_eq!(contacts.first(), Some(&id));
|
||||
let contacts = Contact::get_all(&context, 0, Some("alice@")).await?;
|
||||
assert_eq!(contacts.len(), 0);
|
||||
|
||||
let contacts = Contact::get_all(&context, 0, Some("Foobar")).await?;
|
||||
assert_eq!(contacts.len(), 0);
|
||||
|
||||
// Set Alice name to "someone" manually.
|
||||
id.set_name(&context, "someone").await?;
|
||||
// Set Alice name manually.
|
||||
id.set_name(&context, "Δ-someone").await?;
|
||||
let contact = Contact::get_by_id(&context.ctx, id).await.unwrap();
|
||||
assert_eq!(contact.get_name(), "someone");
|
||||
assert_eq!(contact.get_authname(), "MyName");
|
||||
assert_eq!(contact.get_display_name(), "someone");
|
||||
assert_eq!(contact.get_name(), "Δ-someone");
|
||||
assert_eq!(contact.get_authname(), "MyNameIsΔ");
|
||||
assert_eq!(contact.get_display_name(), "Δ-someone");
|
||||
|
||||
// Not searchable by authname, because it is not displayed.
|
||||
let contacts = Contact::get_all(&context, 0, Some("MyName")).await?;
|
||||
@@ -108,7 +113,9 @@ async fn test_get_contacts() -> Result<()> {
|
||||
info!(&context, "add_self={add_self}");
|
||||
|
||||
// Search key-contacts by display name (same as manually set name).
|
||||
let contacts = Contact::get_all(&context.ctx, add_self, Some("someone")).await?;
|
||||
let contacts = Contact::get_all(&context.ctx, add_self, Some("Δ-someone")).await?;
|
||||
assert_eq!(contacts, vec![id]);
|
||||
let contacts = Contact::get_all(&context.ctx, add_self, Some("δ-someon")).await?;
|
||||
assert_eq!(contacts, vec![id]);
|
||||
|
||||
// Get all key-contacts.
|
||||
@@ -120,7 +127,7 @@ async fn test_get_contacts() -> Result<()> {
|
||||
}
|
||||
|
||||
// Search address-contacts by display name.
|
||||
let contacts = Contact::get_all(&context, constants::DC_GCL_ADDRESS, Some("someone")).await?;
|
||||
let contacts = Contact::get_all(&context, constants::DC_GCL_ADDRESS, Some("Δ-someone")).await?;
|
||||
assert_eq!(contacts, vec![claire_id]);
|
||||
|
||||
// Get all address-contacts. Newer contacts go first.
|
||||
@@ -134,6 +141,16 @@ async fn test_get_contacts() -> Result<()> {
|
||||
.await?;
|
||||
assert_eq!(contacts, vec![dave_id, claire_id, ContactId::SELF]);
|
||||
|
||||
// Reset the user-provided name for Alice.
|
||||
id.set_name(&context, "").await?;
|
||||
let contact = Contact::get_by_id(&context.ctx, id).await.unwrap();
|
||||
assert_eq!(contact.get_name(), "");
|
||||
assert_eq!(contact.get_authname(), "MyNameIsΔ");
|
||||
assert_eq!(contact.get_display_name(), "MyNameIsΔ");
|
||||
let contacts = Contact::get_all(&context, 0, Some("MyName")).await?;
|
||||
assert_eq!(contacts.len(), 1);
|
||||
let contacts = Contact::get_all(&context, 0, Some("δ")).await?;
|
||||
assert_eq!(contacts.len(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -806,7 +823,7 @@ async fn test_contact_get_encrinfo() -> Result<()> {
|
||||
|
||||
let address_contact_bob_id = alice.add_or_lookup_address_contact_id(bob).await;
|
||||
let encrinfo = Contact::get_encrinfo(alice, address_contact_bob_id).await?;
|
||||
assert_eq!(encrinfo, "No encryption");
|
||||
assert_eq!(encrinfo, "No encryption.");
|
||||
|
||||
let contact = Contact::get_by_id(alice, address_contact_bob_id).await?;
|
||||
assert!(!contact.e2ee_avail(alice).await?);
|
||||
@@ -815,7 +832,7 @@ async fn test_contact_get_encrinfo() -> Result<()> {
|
||||
let encrinfo = Contact::get_encrinfo(alice, contact_bob_id).await?;
|
||||
assert_eq!(
|
||||
encrinfo,
|
||||
"End-to-end encryption available.
|
||||
"Messages are end-to-end encrypted.
|
||||
Fingerprints:
|
||||
|
||||
Me (alice@example.org):
|
||||
@@ -824,7 +841,10 @@ Me (alice@example.org):
|
||||
|
||||
bob@example.net (bob@example.net):
|
||||
CCCB 5AA9 F6E1 141C 9431
|
||||
65F1 DB18 B18C BCF7 0487"
|
||||
65F1 DB18 B18C BCF7 0487
|
||||
|
||||
Relays:
|
||||
bob@example.net"
|
||||
);
|
||||
let contact = Contact::get_by_id(alice, contact_bob_id).await?;
|
||||
assert!(contact.e2ee_avail(alice).await?);
|
||||
@@ -850,8 +870,7 @@ CCCB 5AA9 F6E1 141C 9431
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that status is synchronized when sending encrypted BCC-self messages and not
|
||||
/// synchronized when the message is not encrypted.
|
||||
/// Tests that self-status is not synchronized from outgoing messages.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_synchronize_status() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
@@ -870,21 +889,12 @@ async fn test_synchronize_status() -> Result<()> {
|
||||
.await?;
|
||||
let chat = alice1.create_email_chat(bob).await;
|
||||
|
||||
// Alice sends a message to Bob from the first device.
|
||||
// Alice sends an unencrypted message to Bob from the first device.
|
||||
send_text_msg(alice1, chat.id, "Hello".to_string()).await?;
|
||||
let sent_msg = alice1.pop_sent_msg().await;
|
||||
|
||||
// Message is not encrypted.
|
||||
let message = sent_msg.load_from_db().await;
|
||||
assert!(!message.get_showpadlock());
|
||||
|
||||
// Alice's second devices receives a copy of outgoing message.
|
||||
alice2.recv_msg(&sent_msg).await;
|
||||
|
||||
// Bob receives message.
|
||||
bob.recv_msg(&sent_msg).await;
|
||||
|
||||
// Message was not encrypted, so status is not copied.
|
||||
assert_eq!(alice2.get_config(Config::Selfstatus).await?, default_status);
|
||||
|
||||
// Alice sends encrypted message.
|
||||
@@ -892,17 +902,9 @@ async fn test_synchronize_status() -> Result<()> {
|
||||
send_text_msg(alice1, chat.id, "Hello".to_string()).await?;
|
||||
let sent_msg = alice1.pop_sent_msg().await;
|
||||
|
||||
// Second message is encrypted.
|
||||
let message = sent_msg.load_from_db().await;
|
||||
assert!(message.get_showpadlock());
|
||||
|
||||
// Alice's second devices receives a copy of second outgoing message.
|
||||
alice2.recv_msg(&sent_msg).await;
|
||||
|
||||
assert_eq!(
|
||||
alice2.get_config(Config::Selfstatus).await?,
|
||||
Some("New status".to_string())
|
||||
);
|
||||
assert_eq!(alice2.get_config(Config::Selfstatus).await?, default_status);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -915,9 +917,9 @@ async fn test_selfavatar_changed_event() -> Result<()> {
|
||||
// Alice has two devices.
|
||||
let alice1 = &tcm.alice().await;
|
||||
let alice2 = &tcm.alice().await;
|
||||
|
||||
// Bob has one device.
|
||||
let bob = &tcm.bob().await;
|
||||
for a in [alice1, alice2] {
|
||||
a.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
}
|
||||
|
||||
assert_eq!(alice1.get_config(Config::Selfavatar).await?, None);
|
||||
|
||||
@@ -933,17 +935,7 @@ async fn test_selfavatar_changed_event() -> Result<()> {
|
||||
.get_matching(|e| matches!(e, EventType::SelfavatarChanged))
|
||||
.await;
|
||||
|
||||
// Alice sends a message.
|
||||
let alice1_chat_id = alice1.create_chat(bob).await.id;
|
||||
send_text_msg(alice1, alice1_chat_id, "Hello".to_string()).await?;
|
||||
let sent_msg = alice1.pop_sent_msg().await;
|
||||
|
||||
// The message is encrypted.
|
||||
let message = sent_msg.load_from_db().await;
|
||||
assert!(message.get_showpadlock());
|
||||
|
||||
// Alice's second device receives a copy of the outgoing message.
|
||||
alice2.recv_msg(&sent_msg).await;
|
||||
sync(alice1, alice2).await;
|
||||
|
||||
// Alice's second device applies the selfavatar.
|
||||
assert!(alice2.get_config(Config::Selfavatar).await?.is_some());
|
||||
@@ -1156,8 +1148,11 @@ async fn test_make_n_import_vcard() -> Result<()> {
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
bob.set_config(Config::Displayname, Some("Bob")).await?;
|
||||
bob.set_config(Config::Selfstatus, Some("It's me, bob"))
|
||||
.await?;
|
||||
bob.set_config(
|
||||
Config::Selfstatus,
|
||||
Some("It's me,\nbob; and here's a backslash: \\"),
|
||||
)
|
||||
.await?;
|
||||
let avatar_path = bob.dir.path().join("avatar.png");
|
||||
let avatar_bytes = include_bytes!("../../test-data/image/avatar64x64.png");
|
||||
let avatar_base64 = base64::engine::general_purpose::STANDARD.encode(avatar_bytes);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user