mirror of
https://github.com/chatmail/core.git
synced 2026-04-02 05:22:14 +03:00
Compare commits
349 Commits
v2.32.0
...
link2xt/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ac9c6bb09 | ||
|
|
84459b6495 | ||
|
|
c99b8a4482 | ||
|
|
76e2c36d85 | ||
|
|
1b8bf4ed23 | ||
|
|
c553357c60 | ||
|
|
ebe8550c52 | ||
|
|
2637c3bea4 | ||
|
|
d1f1633c60 | ||
|
|
98b55ec15f | ||
|
|
6a3ef20a99 | ||
|
|
59be03a7eb | ||
|
|
8528184fa3 | ||
|
|
5ab1fdca2e | ||
|
|
f616d1bd6c | ||
|
|
e885e052c3 | ||
|
|
6b1e62faba | ||
|
|
7b9e7ae611 | ||
|
|
aedc60f1cc | ||
|
|
017099215c | ||
|
|
e86b170969 | ||
|
|
452ac8a1bc | ||
|
|
5d06ca3c8e | ||
|
|
bdc9e7ce56 | ||
|
|
e30d833c94 | ||
|
|
16668b45e9 | ||
|
|
b148be2618 | ||
|
|
191e6c2821 | ||
|
|
7b700591f4 | ||
|
|
98f03743c6 | ||
|
|
bcaf1284e2 | ||
|
|
fba4e63961 | ||
|
|
810dab12dc | ||
|
|
c0cc2ae816 | ||
|
|
528305e12b | ||
|
|
6e0586058d | ||
|
|
296ed6d74a | ||
|
|
8116460f14 | ||
|
|
52f4293bc5 | ||
|
|
cff0192e38 | ||
|
|
6f17a86903 | ||
|
|
4eb77d5a83 | ||
|
|
e06372c954 | ||
|
|
50cd2514cd | ||
|
|
ba00251572 | ||
|
|
e690186236 | ||
|
|
e14151d6cc | ||
|
|
c6cdccdb97 | ||
|
|
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 |
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
@@ -20,7 +20,7 @@ permissions: {}
|
||||
|
||||
env:
|
||||
RUSTFLAGS: -Dwarnings
|
||||
RUST_VERSION: 1.91.0
|
||||
RUST_VERSION: 1.94.0
|
||||
|
||||
# Minimum Supported Rust Version
|
||||
MSRV: 1.88.0
|
||||
@@ -40,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
|
||||
@@ -59,9 +59,9 @@ jobs:
|
||||
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"
|
||||
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -134,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
|
||||
|
||||
@@ -168,13 +168,13 @@ jobs:
|
||||
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@v7
|
||||
with:
|
||||
name: ${{ matrix.os }}-libdeltachat.a
|
||||
path: target/debug/libdeltachat.a
|
||||
@@ -194,13 +194,13 @@ jobs:
|
||||
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@v7
|
||||
with:
|
||||
name: ${{ matrix.os }}-deltachat-rpc-server
|
||||
path: ${{ matrix.os == 'windows-latest' && 'target/debug/deltachat-rpc-server.exe' || 'target/debug/deltachat-rpc-server' }}
|
||||
@@ -243,7 +243,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download libdeltachat.a
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ubuntu-latest-libdeltachat.a
|
||||
path: target/debug
|
||||
@@ -293,7 +293,7 @@ jobs:
|
||||
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
|
||||
@@ -355,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
|
||||
|
||||
94
.github/workflows/deltachat-rpc-server.yml
vendored
94
.github/workflows/deltachat-rpc-server.yml
vendored
@@ -34,13 +34,13 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # 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@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-${{ matrix.arch }}-linux
|
||||
path: result/bin/deltachat-rpc-server
|
||||
@@ -58,13 +58,13 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31
|
||||
- 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@v5
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-${{ matrix.arch }}-linux-wheel
|
||||
path: result/*.whl
|
||||
@@ -82,13 +82,13 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # 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@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-${{ matrix.arch }}
|
||||
path: result/bin/deltachat-rpc-server.exe
|
||||
@@ -106,13 +106,13 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31
|
||||
- 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@v5
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-${{ matrix.arch }}-wheel
|
||||
path: result/*.whl
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
run: cargo build --release --package deltachat-rpc-server --target ${{ matrix.arch }}-apple-darwin --features vendored
|
||||
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-${{ matrix.arch }}-macos
|
||||
path: target/${{ matrix.arch }}-apple-darwin/release/deltachat-rpc-server
|
||||
@@ -157,13 +157,13 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # 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@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-${{ matrix.arch }}-android
|
||||
path: result/bin/deltachat-rpc-server
|
||||
@@ -181,13 +181,13 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31
|
||||
- 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@v5
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-${{ matrix.arch }}-android-wheel
|
||||
path: result/*.whl
|
||||
@@ -208,124 +208,124 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # 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@v6
|
||||
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@v6
|
||||
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@v6
|
||||
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@v6
|
||||
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@v6
|
||||
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@v6
|
||||
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@v6
|
||||
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@v6
|
||||
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@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-armeabi-v7a-android-wheel
|
||||
path: deltachat-rpc-server-armeabi-v7a-android-wheel.d
|
||||
@@ -382,7 +382,7 @@ jobs:
|
||||
|
||||
- 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
|
||||
@@ -406,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
|
||||
@@ -496,7 +496,7 @@ jobs:
|
||||
ls -lah
|
||||
|
||||
- name: Upload to artifacts
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: deltachat-rpc-server-npm-package
|
||||
path: deltachat-rpc-server/npm-package/*.tgz
|
||||
|
||||
2
.github/workflows/jsonrpc.yml
vendored
2
.github/workflows/jsonrpc.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/nix.yml
vendored
6
.github/workflows/nix.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
- run: nix fmt flake.nix -- --check
|
||||
|
||||
build:
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
- run: nix build .#${{ matrix.installable }}
|
||||
|
||||
build-macos:
|
||||
@@ -105,5 +105,5 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
- run: nix build .#${{ matrix.installable }}
|
||||
|
||||
@@ -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@v7
|
||||
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
|
||||
|
||||
4
.github/workflows/repl.yml
vendored
4
.github/workflows/repl.yml
vendored
@@ -18,11 +18,11 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # 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@v7
|
||||
with:
|
||||
name: repl.exe
|
||||
path: "result/bin/deltachat-repl.exe"
|
||||
|
||||
4
.github/workflows/upload-docs.yml
vendored
4
.github/workflows/upload-docs.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
fetch-depth: 0 # Fetch history to calculate VCS version number.
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # 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
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
fetch-depth: 0 # Fetch history to calculate VCS version number.
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
- name: Build C documentation
|
||||
run: nix build .#docs
|
||||
- name: Upload to c.delta.chat
|
||||
|
||||
19
.github/workflows/zizmor-scan.yml
vendored
19
.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@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244
|
||||
|
||||
- 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@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
||||
|
||||
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
|
||||
626
CHANGELOG.md
626
CHANGELOG.md
@@ -1,5 +1,614 @@
|
||||
# Changelog
|
||||
|
||||
## [2.47.0] - 2026-03-24
|
||||
|
||||
### Fixes
|
||||
|
||||
- Don't fall into infinite loop if the folder is missing ([#8021](https://github.com/chatmail/core/pull/8021)).
|
||||
- Delete `available_post_msgs` row if the message is already downloaded.
|
||||
- Delete `available_post_msgs` row if there is no corresponding IMAP entry.
|
||||
- Make newlines work in chat descriptions ([#8012](https://github.com/chatmail/core/pull/8012)).
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- use SEIPDv2 if all recipients support it.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Add shadowsocks spec to standards.md.
|
||||
- Document Header Confidentiality Policy.
|
||||
- `deltachat_rpc_client`: make sphinx documentation display method parameters.
|
||||
- Remove `draft/aeap-mvp.md` which is superseded by key-contacts and multi-relay.
|
||||
|
||||
### Refactor
|
||||
|
||||
- Remove code to send messages without intended recipient fingerprint.
|
||||
|
||||
### Tests
|
||||
|
||||
- Make `add_or_lookup_contact_id_no_key` public.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- cargo: bump sdp from 0.10.0 to 0.17.1.
|
||||
- Add RUSTSEC-2026-0049 exception to deny.toml.
|
||||
|
||||
## [2.46.0] - 2026-03-19
|
||||
|
||||
### API-Changes
|
||||
|
||||
- [**breaking**] remove functions for sending and receiving Autocrypt Setup Message.
|
||||
- Add `list_transports_ex()` and `set_transport_unpublished()` functions.
|
||||
- Add API `dc_markfresh_chat` to mark messages as "fresh".
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- add `IncomingCallAccepted.from_this_device`.
|
||||
- decode `dcaccount://` URLs and error out on empty URLs early.
|
||||
- enable anonymous OpenPGP key IDs.
|
||||
- tls: do not verify TLS certificates for hostnames starting with `_`.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Mark call message as seen when accepting/declining a call ([#7842](https://github.com/chatmail/core/pull/7842)).
|
||||
- do not send MDNs for hidden messages.
|
||||
- call sync_all() instead of sync_data() when writing accounts.toml.
|
||||
- fsync() the rename() of accounts.toml.
|
||||
- count recipients by Intended Recipient Fingerprints.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- deps: bump zizmorcore/zizmor-action from 0.5.0 to 0.5.2.
|
||||
- cargo: bump astral-tokio-tar from 0.5.6 to 0.6.0.
|
||||
- deps: bump actions/upload-artifact from 6 to 7.
|
||||
- cargo: bump blake3 from 1.8.2 to 1.8.3.
|
||||
- add constant_time_eq 0.3.1 to deny.toml.
|
||||
|
||||
### Refactor
|
||||
|
||||
- use re-exported rustls::pki_types.
|
||||
- import tokio_rustls::rustls.
|
||||
- Move transport_tests to their own file.
|
||||
|
||||
### Tests
|
||||
|
||||
- Shift time even more in flaky test_sync_broadcast_and_send_message.
|
||||
- test markfresh_chat()
|
||||
|
||||
## [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
|
||||
@@ -7,7 +616,7 @@ 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)).
|
||||
- `lookup_or_create_adhoc_group()`: Add context to SQL errors ([#7554](https://github.com/chatmail/core/pull/7554)).
|
||||
|
||||
## [2.31.0] - 2025-12-04
|
||||
|
||||
@@ -7360,3 +7969,18 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
|
||||
[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
|
||||
[2.46.0]: https://github.com/chatmail/core/compare/v2.45.0..v2.46.0
|
||||
[2.47.0]: https://github.com/chatmail/core/compare/v2.46.0..v2.47.0
|
||||
|
||||
657
Cargo.lock
generated
657
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
32
Cargo.toml
32
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat"
|
||||
version = "2.32.0"
|
||||
version = "2.48.0-dev"
|
||||
edition = "2024"
|
||||
license = "MPL-2.0"
|
||||
rust-version = "1.88"
|
||||
@@ -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,16 @@ num-derive = "0.4"
|
||||
num-traits = { workspace = true }
|
||||
parking_lot = "0.12.4"
|
||||
percent-encoding = "2.3"
|
||||
pgp = { version = "0.18.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.10.0"
|
||||
sdp = "0.17.1"
|
||||
serde_json = { workspace = true }
|
||||
serde_urlencoded = "0.7.1"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
@@ -96,26 +95,27 @@ 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 }
|
||||
tokio-io-timeout = "1.2.1"
|
||||
tokio-rustls = { version = "0.26.2", default-features = false }
|
||||
tokio-stream = { version = "0.1.17", features = ["fs"] }
|
||||
astral-tokio-tar = { version = "0.5.6", default-features = false }
|
||||
astral-tokio-tar = { version = "0.6", default-features = false }
|
||||
tokio-util = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] }
|
||||
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 +181,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 +193,15 @@ mailparse = "0.16.1"
|
||||
nu-ansi-term = "0.50"
|
||||
num-traits = "0.2"
|
||||
rand = "0.9"
|
||||
regex = "1.10"
|
||||
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.17"
|
||||
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.
|
||||
|
||||
2
STYLE.md
2
STYLE.md
@@ -21,7 +21,7 @@ text TEXT DEFAULT '' NOT NULL -- message text
|
||||
```
|
||||
|
||||
Do not use macros like [`concat!`](https://doc.rust-lang.org/std/macro.concat.html)
|
||||
or [`indoc!](https://docs.rs/indoc).
|
||||
or [`indoc!`](https://docs.rs/indoc).
|
||||
Do not escape newlines like this:
|
||||
```
|
||||
sql.execute(
|
||||
|
||||
@@ -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, SeipdVersion, 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,66 +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())],
|
||||
key_pair.secret.clone(),
|
||||
true,
|
||||
true,
|
||||
SeipdVersion::V2,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
b.iter(|| {
|
||||
let mut msg = decrypt(
|
||||
encrypted.clone().into_bytes(),
|
||||
std::slice::from_ref(&key_pair.secret),
|
||||
black_box(&secrets),
|
||||
)
|
||||
.unwrap();
|
||||
let decrypted = msg.as_data_vec().unwrap();
|
||||
|
||||
assert_eq!(black_box(decrypted), plain);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================================
|
||||
// Benchmarks for the whole parsing pipeline, incl. decryption (but excl. receive_imf())
|
||||
// ===========================================================================================
|
||||
@@ -139,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;
|
||||
@@ -148,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
|
||||
});
|
||||
|
||||
@@ -161,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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -176,7 +122,7 @@ fn criterion_benchmark(c: &mut Criterion) {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(text, "hi");
|
||||
assert_eq!(black_box(text), "hi");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -185,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.32.0"
|
||||
version = "2.48.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 _;
|
||||
@@ -306,20 +306,17 @@ pub unsafe extern "C" fn dc_set_stock_translation(
|
||||
let msg = to_string_lossy(stock_msg);
|
||||
let ctx = &*context;
|
||||
|
||||
block_on(async move {
|
||||
match StockMessage::from_u32(stock_id)
|
||||
.with_context(|| format!("Invalid stock message ID {stock_id}"))
|
||||
match StockMessage::from_u32(stock_id)
|
||||
.with_context(|| format!("Invalid stock message ID {stock_id}"))
|
||||
.log_err(ctx)
|
||||
{
|
||||
Ok(id) => ctx
|
||||
.set_stock_translation(id, msg)
|
||||
.context("set_stock_translation failed")
|
||||
.log_err(ctx)
|
||||
{
|
||||
Ok(id) => ctx
|
||||
.set_stock_translation(id, msg)
|
||||
.await
|
||||
.context("set_stock_translation failed")
|
||||
.log_err(ctx)
|
||||
.is_ok() as libc::c_int,
|
||||
Err(_) => 0,
|
||||
}
|
||||
})
|
||||
.is_ok() as libc::c_int,
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
@@ -680,7 +677,6 @@ 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 { .. }
|
||||
@@ -703,6 +699,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)]
|
||||
@@ -1182,6 +1181,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()");
|
||||
@@ -1191,7 +1191,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())
|
||||
@@ -1520,6 +1520,23 @@ pub unsafe extern "C" fn dc_marknoticed_chat(context: *mut dc_context_t, chat_id
|
||||
})
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_markfresh_chat(context: *mut dc_context_t, chat_id: u32) {
|
||||
if context.is_null() {
|
||||
eprintln!("ignoring careless call to dc_markfresh_chat()");
|
||||
return;
|
||||
}
|
||||
let ctx = &*context;
|
||||
|
||||
block_on(async move {
|
||||
chat::markfresh_chat(ctx, ChatId::new(chat_id))
|
||||
.await
|
||||
.context("Failed markfresh chat")
|
||||
.log_err(ctx)
|
||||
.unwrap_or(())
|
||||
})
|
||||
}
|
||||
|
||||
fn from_prim<S, T>(s: S) -> Option<T>
|
||||
where
|
||||
T: FromPrimitive,
|
||||
@@ -2261,22 +2278,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,
|
||||
@@ -2442,45 +2443,6 @@ pub unsafe extern "C" fn dc_imex_has_backup(
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_initiate_key_transfer(context: *mut dc_context_t) -> *mut libc::c_char {
|
||||
if context.is_null() {
|
||||
eprintln!("ignoring careless call to dc_initiate_key_transfer()");
|
||||
return ptr::null_mut(); // NULL explicitly defined as "error"
|
||||
}
|
||||
let ctx = &*context;
|
||||
|
||||
match block_on(imex::initiate_key_transfer(ctx))
|
||||
.context("dc_initiate_key_transfer()")
|
||||
.log_err(ctx)
|
||||
{
|
||||
Ok(res) => res.strdup(),
|
||||
Err(_) => ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_continue_key_transfer(
|
||||
context: *mut dc_context_t,
|
||||
msg_id: u32,
|
||||
setup_code: *const libc::c_char,
|
||||
) -> libc::c_int {
|
||||
if context.is_null() || msg_id <= constants::DC_MSG_ID_LAST_SPECIAL || setup_code.is_null() {
|
||||
eprintln!("ignoring careless call to dc_continue_key_transfer()");
|
||||
return 0;
|
||||
}
|
||||
let ctx = &*context;
|
||||
|
||||
block_on(imex::continue_key_transfer(
|
||||
ctx,
|
||||
MsgId::new(msg_id),
|
||||
&to_string_lossy(setup_code),
|
||||
))
|
||||
.context("dc_continue_key_transfer")
|
||||
.log_err(ctx)
|
||||
.is_ok() as libc::c_int
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_stop_ongoing_process(context: *mut dc_context_t) {
|
||||
if context.is_null() {
|
||||
@@ -3803,16 +3765,6 @@ pub unsafe extern "C" fn dc_msg_get_webxdc_href(msg: *mut dc_msg_t) -> *mut libc
|
||||
ffi_msg.message.get_webxdc_href().strdup()
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_msg_is_setupmessage(msg: *mut dc_msg_t) -> libc::c_int {
|
||||
if msg.is_null() {
|
||||
eprintln!("ignoring careless call to dc_msg_is_setupmessage()");
|
||||
return 0;
|
||||
}
|
||||
let ffi_msg = &*msg;
|
||||
ffi_msg.message.is_setupmessage().into()
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_msg_has_html(msg: *mut dc_msg_t) -> libc::c_int {
|
||||
if msg.is_null() {
|
||||
@@ -3823,20 +3775,6 @@ pub unsafe extern "C" fn dc_msg_has_html(msg: *mut dc_msg_t) -> libc::c_int {
|
||||
ffi_msg.message.has_html().into()
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_msg_get_setupcodebegin(msg: *mut dc_msg_t) -> *mut libc::c_char {
|
||||
if msg.is_null() {
|
||||
eprintln!("ignoring careless call to dc_msg_get_setupcodebegin()");
|
||||
return "".strdup();
|
||||
}
|
||||
let ffi_msg = &*msg;
|
||||
let ctx = &*ffi_msg.context;
|
||||
|
||||
block_on(ffi_msg.message.get_setupcodebegin(ctx))
|
||||
.unwrap_or_default()
|
||||
.strdup()
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_msg_set_text(msg: *mut dc_msg_t, text: *const libc::c_char) {
|
||||
if msg.is_null() {
|
||||
@@ -4739,33 +4677,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() {
|
||||
@@ -4776,7 +4694,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:#}");
|
||||
@@ -4789,17 +4799,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() {
|
||||
@@ -4816,7 +4826,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()");
|
||||
@@ -4832,7 +4842,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() {
|
||||
@@ -4856,13 +4866,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;
|
||||
@@ -4877,13 +4887,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;
|
||||
@@ -4899,7 +4909,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() {
|
||||
@@ -4907,7 +4917,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;
|
||||
@@ -4925,7 +4935,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() {
|
||||
@@ -4933,7 +4943,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 {
|
||||
@@ -4954,7 +4964,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();
|
||||
@@ -4968,18 +4978,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;
|
||||
@@ -4990,7 +5000,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;
|
||||
@@ -5001,7 +5011,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;
|
||||
@@ -5013,7 +5023,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 {
|
||||
@@ -5032,7 +5042,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;
|
||||
@@ -5044,7 +5054,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() {
|
||||
@@ -5067,7 +5077,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()");
|
||||
@@ -5087,17 +5097,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.32.0"
|
||||
version = "2.48.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,11 +10,12 @@ 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, markfresh_chat, 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;
|
||||
@@ -22,26 +23,25 @@ use deltachat::ephemeral::Timer;
|
||||
use deltachat::imex;
|
||||
use deltachat::location;
|
||||
use deltachat::message::{
|
||||
self, delete_msgs_ex, get_existing_msg_ids, get_msg_read_receipts, 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_storage_usage;
|
||||
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;
|
||||
@@ -68,6 +68,7 @@ use self::types::{
|
||||
},
|
||||
};
|
||||
use crate::api::types::chat_list::{get_chat_list_item_by_id, ChatListItemFetchResult};
|
||||
use crate::api::types::login_param::TransportListEntry;
|
||||
use crate::api::types::qr::{QrObject, SecurejoinSource, SecurejoinUiPath};
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -194,6 +195,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
|
||||
// ---------------------------------------------
|
||||
@@ -329,13 +340,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)
|
||||
}
|
||||
@@ -422,11 +427,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
|
||||
@@ -458,13 +463,17 @@ 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 {
|
||||
if let Some(stock_id) = StockMessage::from_u32(stock_id) {
|
||||
accounts
|
||||
.set_stock_translation(stock_id, stock_message)
|
||||
.await?;
|
||||
accounts.set_stock_translation(stock_id, stock_message)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -518,6 +527,7 @@ impl CommandApi {
|
||||
/// from a server encoded in a QR code.
|
||||
/// - [Self::list_transports()] to get a list of all configured transports.
|
||||
/// - [Self::delete_transport()] to remove a transport.
|
||||
/// - [Self::set_transport_unpublished()] to set whether contacts see this transport.
|
||||
async fn add_or_update_transport(
|
||||
&self,
|
||||
account_id: u32,
|
||||
@@ -543,7 +553,23 @@ impl CommandApi {
|
||||
/// Returns the list of all email accounts that are used as a transport in the current profile.
|
||||
/// Use [Self::add_or_update_transport()] to add or change a transport
|
||||
/// and [Self::delete_transport()] to delete a transport.
|
||||
/// Use [Self::list_transports_ex()] to additionally query
|
||||
/// whether the transports are marked as 'unpublished'.
|
||||
async fn list_transports(&self, account_id: u32) -> Result<Vec<EnteredLoginParam>> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
let res = ctx
|
||||
.list_transports()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|t| t.param.into())
|
||||
.collect();
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// Returns the list of all email accounts that are used as a transport in the current profile.
|
||||
/// Use [Self::add_or_update_transport()] to add or change a transport
|
||||
/// and [Self::delete_transport()] to delete a transport.
|
||||
async fn list_transports_ex(&self, account_id: u32) -> Result<Vec<TransportListEntry>> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
let res = ctx
|
||||
.list_transports()
|
||||
@@ -561,6 +587,26 @@ impl CommandApi {
|
||||
ctx.delete_transport(&addr).await
|
||||
}
|
||||
|
||||
/// Change whether the transport is unpublished.
|
||||
///
|
||||
/// Unpublished transports are not advertised to contacts,
|
||||
/// and self-sent messages are not sent there,
|
||||
/// so that we don't cause extra messages to the corresponding inbox,
|
||||
/// but can still receive messages from contacts who don't know our new transport addresses yet.
|
||||
///
|
||||
/// The default is false, but when the user updates from a version that didn't have this flag,
|
||||
/// existing secondary transports are set to unpublished,
|
||||
/// so that an existing transport address doesn't suddenly get spammed with a lot of messages.
|
||||
async fn set_transport_unpublished(
|
||||
&self,
|
||||
account_id: u32,
|
||||
addr: String,
|
||||
unpublished: bool,
|
||||
) -> Result<()> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
ctx.set_transport_unpublished(&addr, unpublished).await
|
||||
}
|
||||
|
||||
/// Signal an ongoing process to stop.
|
||||
async fn stop_ongoing_process(&self, account_id: u32) -> Result<()> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
@@ -689,25 +735,6 @@ impl CommandApi {
|
||||
message::estimate_deletion_cnt(&ctx, from_server, seconds).await
|
||||
}
|
||||
|
||||
// ---------------------------------------------
|
||||
// autocrypt
|
||||
// ---------------------------------------------
|
||||
|
||||
async fn initiate_autocrypt_key_transfer(&self, account_id: u32) -> Result<String> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
deltachat::imex::initiate_key_transfer(&ctx).await
|
||||
}
|
||||
|
||||
async fn continue_autocrypt_key_transfer(
|
||||
&self,
|
||||
account_id: u32,
|
||||
message_id: u32,
|
||||
setup_code: String,
|
||||
) -> Result<()> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
deltachat::imex::continue_key_transfer(&ctx, MsgId::new(message_id), &setup_code).await
|
||||
}
|
||||
|
||||
// ---------------------------------------------
|
||||
// chat list
|
||||
// ---------------------------------------------
|
||||
@@ -801,11 +828,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
|
||||
@@ -854,6 +881,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.
|
||||
@@ -1068,7 +1097,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<()> {
|
||||
@@ -1076,10 +1106,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.
|
||||
///
|
||||
@@ -1164,10 +1223,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().
|
||||
@@ -1176,6 +1249,15 @@ impl CommandApi {
|
||||
marknoticed_chat(&ctx, ChatId::new(chat_id)).await
|
||||
}
|
||||
|
||||
/// Marks the last incoming message in the chat as _fresh_.
|
||||
///
|
||||
/// UI can use this to offer a "mark unread" option,
|
||||
/// so that already noticed chats get a badge counter again.
|
||||
async fn markfresh_chat(&self, account_id: u32, chat_id: u32) -> Result<()> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
markfresh_chat(&ctx, ChatId::new(chat_id)).await
|
||||
}
|
||||
|
||||
/// Returns the message that is immediately followed by the last seen
|
||||
/// message.
|
||||
/// From the point of view of the user this is effectively
|
||||
@@ -1434,6 +1516,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,
|
||||
@@ -1914,6 +2008,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?;
|
||||
@@ -1927,6 +2023,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
|
||||
@@ -2141,10 +2242,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())
|
||||
}
|
||||
@@ -2208,6 +2310,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
|
||||
@@ -2418,7 +2541,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()
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -463,11 +465,11 @@ pub enum EventType {
|
||||
|
||||
/// One or more transports has changed.
|
||||
///
|
||||
/// This event is used for tests to detect when transport
|
||||
/// synchronization messages arrives.
|
||||
/// UIs don't need to use it, it is unlikely
|
||||
/// that user modifies transports on multiple
|
||||
/// devices simultaneously.
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -634,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,
|
||||
|
||||
@@ -4,6 +4,16 @@ use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use yerpc::TypeDef;
|
||||
|
||||
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TransportListEntry {
|
||||
/// The login data entered by the user.
|
||||
pub param: EnteredLoginParam,
|
||||
/// Whether this transport is set to 'unpublished'.
|
||||
/// See `set_transport_unpublished` / `setTransportUnpublished` for details.
|
||||
pub is_unpublished: bool,
|
||||
}
|
||||
|
||||
/// Login parameters entered by the user.
|
||||
///
|
||||
/// Usually it will be enough to only set `addr` and `password`,
|
||||
@@ -56,6 +66,15 @@ pub struct EnteredLoginParam {
|
||||
pub oauth2: Option<bool>,
|
||||
}
|
||||
|
||||
impl From<dc::TransportListEntry> for TransportListEntry {
|
||||
fn from(transport: dc::TransportListEntry) -> Self {
|
||||
TransportListEntry {
|
||||
param: transport.param.into(),
|
||||
is_unpublished: transport.is_unpublished,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<dc::EnteredLoginParam> for EnteredLoginParam {
|
||||
fn from(param: dc::EnteredLoginParam) -> Self {
|
||||
let imap_security: Socket = param.imap.security.into();
|
||||
|
||||
@@ -68,7 +68,6 @@ pub struct MessageObject {
|
||||
/// if `show_padlock` is `false`,
|
||||
/// and nothing if it is `true`.
|
||||
show_padlock: bool,
|
||||
is_setupmessage: bool,
|
||||
is_info: bool,
|
||||
is_forwarded: bool,
|
||||
|
||||
@@ -88,10 +87,11 @@ pub struct MessageObject {
|
||||
override_sender_name: Option<String>,
|
||||
sender: ContactObject,
|
||||
|
||||
setup_code_begin: Option<String>,
|
||||
|
||||
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>,
|
||||
|
||||
@@ -223,7 +223,6 @@ impl MessageObject {
|
||||
|
||||
subject: message.get_subject().to_owned(),
|
||||
show_padlock: message.get_showpadlock(),
|
||||
is_setupmessage: message.is_setupmessage(),
|
||||
is_info: message.is_info(),
|
||||
is_forwarded: message.is_forwarded(),
|
||||
is_bot: message.is_bot(),
|
||||
@@ -240,8 +239,6 @@ impl MessageObject {
|
||||
override_sender_name,
|
||||
sender,
|
||||
|
||||
setup_code_begin: message.get_setupcodebegin(context).await,
|
||||
|
||||
file: match message.get_file(context) {
|
||||
Some(path_buf) => path_buf.to_str().map(|s| s.to_owned()),
|
||||
None => None,
|
||||
@@ -385,6 +382,7 @@ impl From<download::DownloadState> for DownloadState {
|
||||
pub enum SystemMessageType {
|
||||
Unknown,
|
||||
GroupNameChanged,
|
||||
GroupDescriptionChanged,
|
||||
GroupImageChanged,
|
||||
MemberAddedToGroup,
|
||||
MemberRemovedFromGroup,
|
||||
@@ -437,6 +435,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.32.0"
|
||||
"version": "2.48.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-repl"
|
||||
version = "2.32.0"
|
||||
version = "2.48.0-dev"
|
||||
license = "MPL-2.0"
|
||||
edition = "2021"
|
||||
repository = "https://github.com/chatmail/core"
|
||||
|
||||
@@ -302,9 +302,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
|
||||
// TODO: reuse commands definition in main.rs.
|
||||
"imex" => println!(
|
||||
"====================Import/Export commands==\n\
|
||||
initiate-key-transfer\n\
|
||||
get-setupcodebegin <msg-id>\n\
|
||||
continue-key-transfer <msg-id> <setup-code>\n\
|
||||
has-backup\n\
|
||||
export-backup\n\
|
||||
import-backup <backup-file>\n\
|
||||
@@ -343,6 +340,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\
|
||||
@@ -407,34 +405,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
|
||||
============================================="
|
||||
),
|
||||
},
|
||||
"initiate-key-transfer" => match initiate_key_transfer(&context).await {
|
||||
Ok(setup_code) => {
|
||||
println!("Setup code for the transferred setup message: {setup_code}",)
|
||||
}
|
||||
Err(err) => bail!("Failed to generate setup code: {err}"),
|
||||
},
|
||||
"get-setupcodebegin" => {
|
||||
ensure!(!arg1.is_empty(), "Argument <msg-id> missing.");
|
||||
let msg_id: MsgId = MsgId::new(arg1.parse()?);
|
||||
let msg = Message::load_from_db(&context, msg_id).await?;
|
||||
if msg.is_setupmessage() {
|
||||
let setupcodebegin = msg.get_setupcodebegin(&context).await;
|
||||
println!(
|
||||
"The setup code for setup message {} starts with: {}",
|
||||
msg_id,
|
||||
setupcodebegin.unwrap_or_default(),
|
||||
);
|
||||
} else {
|
||||
bail!("{msg_id} is no setup message.",);
|
||||
}
|
||||
}
|
||||
"continue-key-transfer" => {
|
||||
ensure!(
|
||||
!arg1.is_empty() && !arg2.is_empty(),
|
||||
"Arguments <msg-id> <setup-code> expected"
|
||||
);
|
||||
continue_key_transfer(&context, MsgId::new(arg1.parse()?), arg2).await?;
|
||||
}
|
||||
"has-backup" => {
|
||||
has_backup(&context, blobdir).await?;
|
||||
}
|
||||
@@ -770,6 +740,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 +1208,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:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,10 +149,7 @@ impl Completer for DcHelper {
|
||||
}
|
||||
}
|
||||
|
||||
const IMEX_COMMANDS: [&str; 13] = [
|
||||
"initiate-key-transfer",
|
||||
"get-setupcodebegin",
|
||||
"continue-key-transfer",
|
||||
const IMEX_COMMANDS: [&str; 10] = [
|
||||
"has-backup",
|
||||
"export-backup",
|
||||
"import-backup",
|
||||
@@ -179,7 +176,7 @@ const DB_COMMANDS: [&str; 11] = [
|
||||
"housekeeping",
|
||||
];
|
||||
|
||||
const CHAT_COMMANDS: [&str; 39] = [
|
||||
const CHAT_COMMANDS: [&str; 40] = [
|
||||
"listchats",
|
||||
"listarchived",
|
||||
"start-realtime",
|
||||
@@ -192,6 +189,7 @@ const CHAT_COMMANDS: [&str; 39] = [
|
||||
"addmember",
|
||||
"removemember",
|
||||
"groupname",
|
||||
"groupdescription",
|
||||
"groupimage",
|
||||
"chatinfo",
|
||||
"sendlocations",
|
||||
@@ -430,12 +428,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
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "deltachat-rpc-client"
|
||||
version = "2.32.0"
|
||||
version = "2.48.0-dev"
|
||||
license = "MPL-2.0"
|
||||
description = "Python client for Delta Chat core JSON-RPC interface"
|
||||
classifiers = [
|
||||
|
||||
@@ -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:
|
||||
@@ -182,9 +190,6 @@ class futuremethod: # noqa: N801
|
||||
self._func = func
|
||||
|
||||
def __get__(self, instance, owner=None):
|
||||
if instance is None:
|
||||
return self
|
||||
|
||||
def future(*args):
|
||||
generator = self._func(instance, *args)
|
||||
res = next(generator)
|
||||
@@ -197,6 +202,7 @@ class futuremethod: # noqa: N801
|
||||
|
||||
return f
|
||||
|
||||
@functools.wraps(self._func)
|
||||
def wrapper(*args):
|
||||
f = future(*args)
|
||||
return f()
|
||||
|
||||
@@ -483,10 +483,6 @@ class Account:
|
||||
passphrase = "" # Importing passphrase-protected keys is currently not supported.
|
||||
self._rpc.import_self_keys(self.id, str(path), passphrase)
|
||||
|
||||
def initiate_autocrypt_key_transfer(self) -> None:
|
||||
"""Send Autocrypt Setup Message."""
|
||||
return self._rpc.initiate_autocrypt_key_transfer(self.id)
|
||||
|
||||
def ice_servers(self) -> list:
|
||||
"""Return ICE servers for WebRTC configuration."""
|
||||
ice_servers_json = self._rpc.ice_servers(self.id)
|
||||
|
||||
@@ -219,10 +219,16 @@ 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 mark_fresh(self) -> None:
|
||||
"""Mark the last incoming message in the chat as fresh."""
|
||||
self._rpc.markfresh_chat(self.account.id, self.id)
|
||||
|
||||
def add_contact(self, *contact: Union[int, str, Contact, "Account"]) -> None:
|
||||
"""Add contacts to this group."""
|
||||
from .account import Account
|
||||
|
||||
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 +236,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 +257,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 +307,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):
|
||||
|
||||
@@ -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)
|
||||
@@ -64,14 +72,6 @@ class Message:
|
||||
"""Return True if the message exists."""
|
||||
return bool(self._rpc.get_existing_msg_ids(self.account.id, [self.id]))
|
||||
|
||||
def continue_autocrypt_key_transfer(self, setup_code: str) -> None:
|
||||
"""Continue the Autocrypt Setup Message key transfer.
|
||||
|
||||
This function can be called on received Autocrypt Setup Message
|
||||
to import the key encrypted with the provided setup code.
|
||||
"""
|
||||
self._rpc.continue_autocrypt_key_transfer(self.account.id, self.id, setup_code)
|
||||
|
||||
def send_webxdc_status_update(self, update: Union[dict, str], description: str) -> None:
|
||||
"""Send a webxdc status update. This message must be a webxdc."""
|
||||
if not isinstance(update, str):
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
@@ -21,7 +22,7 @@ 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."
|
||||
"""
|
||||
|
||||
|
||||
@@ -53,13 +54,13 @@ 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.getenv("CHATMAIL_DOMAIN")
|
||||
domain = os.environ["CHATMAIL_DOMAIN"]
|
||||
return f"dcaccount:{domain}"
|
||||
|
||||
@futuremethod
|
||||
@@ -204,14 +205,13 @@ 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()
|
||||
|
||||
@@ -261,7 +261,7 @@ def get_core_python_env(tmp_path_factory):
|
||||
envs[core_version] = venv
|
||||
python = find_path(venv, "python")
|
||||
rpc_server_path = find_path(venv, "deltachat-rpc-server")
|
||||
print(f"python={python}\nrpc_server={rpc_server_path}")
|
||||
logging.info(f"Paths:\npython={python}\nrpc_server={rpc_server_path}")
|
||||
return python, rpc_server_path
|
||||
|
||||
return get_versioned_venv
|
||||
|
||||
@@ -54,7 +54,12 @@ class RpcMethod:
|
||||
class Rpc:
|
||||
"""RPC client."""
|
||||
|
||||
def __init__(self, accounts_dir: Optional[str] = None, rpc_server_path="deltachat-rpc-server", **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
accounts_dir: Optional[str] = None,
|
||||
rpc_server_path="deltachat-rpc-server",
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize RPC client.
|
||||
|
||||
The 'kwargs' arguments will be passed to subprocess.Popen().
|
||||
@@ -79,8 +84,15 @@ class Rpc:
|
||||
self.events_thread: Thread
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start RPC server subprocess."""
|
||||
popen_kwargs = {"stdin": subprocess.PIPE, "stdout": subprocess.PIPE}
|
||||
"""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):
|
||||
# Prevent subprocess from capturing SIGINT.
|
||||
popen_kwargs["process_group"] = 0
|
||||
@@ -90,6 +102,7 @@ class Rpc:
|
||||
|
||||
popen_kwargs.update(self._kwargs)
|
||||
self.process = subprocess.Popen(self.rpc_server_path, **popen_kwargs)
|
||||
|
||||
self.id_iterator = itertools.count(start=1)
|
||||
self.event_queues = {}
|
||||
self.request_results = {}
|
||||
@@ -102,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
|
||||
@@ -132,6 +161,10 @@ class Rpc:
|
||||
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."""
|
||||
@@ -140,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")
|
||||
@@ -154,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()
|
||||
|
||||
@@ -42,3 +42,16 @@ def test_send_and_receive_message(alice_and_remote_bob) -> None:
|
||||
|
||||
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')")
|
||||
|
||||
@@ -8,8 +8,10 @@ from imap_tools import AND, U
|
||||
from deltachat_rpc_client import Contact, EventType, Message
|
||||
|
||||
|
||||
def test_move_works(acfactory):
|
||||
def test_move_works(acfactory, direct_imap):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
ac2_direct_imap = direct_imap(ac2)
|
||||
ac2_direct_imap.create_folder("DeltaChat")
|
||||
ac2.set_config("mvbox_move", "1")
|
||||
ac2.bring_online()
|
||||
|
||||
@@ -24,68 +26,6 @@ def test_move_works(acfactory):
|
||||
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,
|
||||
their UIDs may be reordered (e.g. Gmail is known for that) which led to that messages were
|
||||
@@ -97,6 +37,8 @@ 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_direct_imap = direct_imap(ac2)
|
||||
ac2_direct_imap.create_folder("DeltaChat")
|
||||
ac2.set_config("mvbox_move", "1")
|
||||
assert ac2.is_configured()
|
||||
|
||||
@@ -130,174 +72,12 @@ 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 "transport 1: UID validity for folder DeltaChat changed from " in event.msg:
|
||||
break
|
||||
|
||||
ac2 = acfactory.get_online_account()
|
||||
ac2.create_chat(ac1).send_text("hello")
|
||||
msg = ac1.wait_for_incoming_msg().get_snapshot()
|
||||
assert msg.text == "hello"
|
||||
|
||||
assert "DeltaChat" in ac1_direct_imap.list_folders()
|
||||
|
||||
|
||||
def test_dont_show_emails(acfactory, direct_imap, log):
|
||||
"""Most mailboxes have a "Drafts" folder where constantly new emails appear but we don't actually want to show them.
|
||||
So: If it's outgoing AND there is no Received header, then ignore the email.
|
||||
|
||||
If the draft email is sent out and received later (i.e. it's in "Inbox"), it must be shown.
|
||||
|
||||
Also, test that unknown emails in the Spam folder are not shown."""
|
||||
ac1 = acfactory.new_configured_account()
|
||||
ac1.stop_io()
|
||||
ac1.set_config("show_emails", "2")
|
||||
|
||||
ac1.create_contact("alice@example.org").create_chat()
|
||||
|
||||
ac1_direct_imap = direct_imap(ac1)
|
||||
ac1_direct_imap.create_folder("Drafts")
|
||||
ac1_direct_imap.create_folder("Spam")
|
||||
ac1_direct_imap.create_folder("Junk")
|
||||
|
||||
# Learn UID validity for all folders.
|
||||
ac1.set_config("scan_all_folders_debounce_secs", "0")
|
||||
ac1.start_io()
|
||||
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
|
||||
ac1.stop_io()
|
||||
|
||||
ac1_direct_imap.append(
|
||||
"Drafts",
|
||||
"""
|
||||
From: ac1 <{}>
|
||||
Subject: subj
|
||||
To: alice@example.org
|
||||
Message-ID: <aepiors@example.org>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
message in Drafts received later
|
||||
""".format(
|
||||
ac1.get_config("configured_addr"),
|
||||
),
|
||||
)
|
||||
ac1_direct_imap.append(
|
||||
"Spam",
|
||||
"""
|
||||
From: unknown.address@junk.org
|
||||
Subject: subj
|
||||
To: {}
|
||||
Message-ID: <spam.message@junk.org>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Unknown message in Spam
|
||||
""".format(
|
||||
ac1.get_config("configured_addr"),
|
||||
),
|
||||
)
|
||||
ac1_direct_imap.append(
|
||||
"Spam",
|
||||
"""
|
||||
From: unknown.address@junk.org, unkwnown.add@junk.org
|
||||
Subject: subj
|
||||
To: {}
|
||||
Message-ID: <spam.message2@junk.org>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Unknown & malformed message in Spam
|
||||
""".format(
|
||||
ac1.get_config("configured_addr"),
|
||||
),
|
||||
)
|
||||
ac1_direct_imap.append(
|
||||
"Spam",
|
||||
"""
|
||||
From: delta<address: inbox@nhroy.com>
|
||||
Subject: subj
|
||||
To: {}
|
||||
Message-ID: <spam.message99@junk.org>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Unknown & malformed message in Spam
|
||||
""".format(
|
||||
ac1.get_config("configured_addr"),
|
||||
),
|
||||
)
|
||||
ac1_direct_imap.append(
|
||||
"Spam",
|
||||
"""
|
||||
From: alice@example.org
|
||||
Subject: subj
|
||||
To: {}
|
||||
Message-ID: <spam.message3@junk.org>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Actually interesting message in Spam
|
||||
""".format(
|
||||
ac1.get_config("configured_addr"),
|
||||
),
|
||||
)
|
||||
ac1_direct_imap.append(
|
||||
"Junk",
|
||||
"""
|
||||
From: unknown.address@junk.org
|
||||
Subject: subj
|
||||
To: {}
|
||||
Message-ID: <spam.message@junk.org>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Unknown message in Junk
|
||||
""".format(
|
||||
ac1.get_config("configured_addr"),
|
||||
),
|
||||
)
|
||||
|
||||
ac1.set_config("scan_all_folders_debounce_secs", "0")
|
||||
log.section("All prepared, now let DC find the message")
|
||||
ac1.start_io()
|
||||
|
||||
# Wait until each folder was scanned, this is necessary for this test to test what it should test:
|
||||
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
|
||||
|
||||
fresh_msgs = list(ac1.get_fresh_messages())
|
||||
msg = fresh_msgs[0].get_snapshot()
|
||||
chat_msgs = msg.chat.get_messages()
|
||||
assert len(chat_msgs) == 1
|
||||
assert msg.text == "subj – Actually interesting message in Spam"
|
||||
|
||||
assert not any("unknown.address" in c.get_full_snapshot().name for c in ac1.get_chatlist())
|
||||
ac1_direct_imap.select_folder("Spam")
|
||||
assert ac1_direct_imap.get_uid_by_message_id("spam.message@junk.org")
|
||||
|
||||
ac1.stop_io()
|
||||
log.section("'Send out' the draft by moving it to Inbox, and wait for DC to display it this time")
|
||||
ac1_direct_imap.select_folder("Drafts")
|
||||
uid = ac1_direct_imap.get_uid_by_message_id("aepiors@example.org")
|
||||
ac1_direct_imap.conn.move(uid, "Inbox")
|
||||
|
||||
ac1.start_io()
|
||||
event = ac1.wait_for_event(EventType.MSGS_CHANGED)
|
||||
msg2 = Message(ac1, event.msg_id).get_snapshot()
|
||||
|
||||
assert msg2.text == "subj – message in Drafts received later"
|
||||
assert len(msg.chat.get_messages()) == 2
|
||||
|
||||
|
||||
def test_move_works_on_self_sent(acfactory):
|
||||
def test_move_works_on_self_sent(acfactory, direct_imap):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
|
||||
# Enable movebox and wait until it is created.
|
||||
# Create and enable movebox.
|
||||
ac1_direct_imap = direct_imap(ac1)
|
||||
ac1_direct_imap.create_folder("DeltaChat")
|
||||
ac1.set_config("mvbox_move", "1")
|
||||
ac1.set_config("bcc_self", "1")
|
||||
ac1.bring_online()
|
||||
@@ -314,6 +94,8 @@ def test_move_works_on_self_sent(acfactory):
|
||||
def test_moved_markseen(acfactory, direct_imap):
|
||||
"""Test that message already moved to DeltaChat folder is marked as seen."""
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
ac2_direct_imap = direct_imap(ac2)
|
||||
ac2_direct_imap.create_folder("DeltaChat")
|
||||
ac2.set_config("mvbox_move", "1")
|
||||
ac2.set_config("delete_server_after", "0")
|
||||
ac2.set_config("sync_msgs", "0") # Do not send a sync message when accepting a contact request.
|
||||
@@ -356,6 +138,8 @@ def test_markseen_message_and_mdn(acfactory, direct_imap, mvbox_move):
|
||||
for ac in ac1, ac2:
|
||||
ac.set_config("delete_server_after", "0")
|
||||
if mvbox_move:
|
||||
ac_direct_imap = direct_imap(ac)
|
||||
ac_direct_imap.create_folder("DeltaChat")
|
||||
ac.set_config("mvbox_move", "1")
|
||||
ac.bring_online()
|
||||
|
||||
@@ -390,120 +174,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 +196,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
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from deltachat_rpc_client import EventType
|
||||
from deltachat_rpc_client.rpc import JsonRpcError
|
||||
|
||||
|
||||
def wait_for_autocrypt_setup_message(account):
|
||||
while True:
|
||||
event = account.wait_for_event()
|
||||
if event.kind == EventType.MSGS_CHANGED and event.msg_id != 0:
|
||||
msg_id = event.msg_id
|
||||
msg = account.get_message_by_id(msg_id)
|
||||
if msg.get_snapshot().is_setupmessage:
|
||||
return msg
|
||||
|
||||
|
||||
def test_autocrypt_setup_message_key_transfer(acfactory):
|
||||
alice1 = acfactory.get_online_account()
|
||||
|
||||
alice2 = acfactory.get_unconfigured_account()
|
||||
alice2.add_or_update_transport({"addr": alice1.get_config("addr"), "password": alice1.get_config("mail_pw")})
|
||||
alice2.bring_online()
|
||||
|
||||
setup_code = alice1.initiate_autocrypt_key_transfer()
|
||||
msg = wait_for_autocrypt_setup_message(alice2)
|
||||
|
||||
# Test that entering wrong code returns an error.
|
||||
with pytest.raises(JsonRpcError):
|
||||
msg.continue_autocrypt_key_transfer("7037-0673-6287-3013-4095-7956-5617-6806-6756")
|
||||
|
||||
msg.continue_autocrypt_key_transfer(setup_code)
|
||||
|
||||
|
||||
def test_ac_setup_message_twice(acfactory):
|
||||
alice1 = acfactory.get_online_account()
|
||||
|
||||
alice2 = acfactory.get_unconfigured_account()
|
||||
alice2.add_or_update_transport({"addr": alice1.get_config("addr"), "password": alice1.get_config("mail_pw")})
|
||||
alice2.bring_online()
|
||||
|
||||
# Send the first Autocrypt Setup Message and ignore it.
|
||||
_setup_code = alice1.initiate_autocrypt_key_transfer()
|
||||
wait_for_autocrypt_setup_message(alice2)
|
||||
|
||||
# Send the second Autocrypt Setup Message and import it.
|
||||
setup_code = alice1.initiate_autocrypt_key_transfer()
|
||||
msg = wait_for_autocrypt_setup_message(alice2)
|
||||
|
||||
msg.continue_autocrypt_key_transfer(setup_code)
|
||||
@@ -1,6 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from deltachat_rpc_client import EventType
|
||||
from deltachat_rpc_client.const import ChatType, DownloadState
|
||||
from deltachat_rpc_client.rpc import JsonRpcError
|
||||
|
||||
|
||||
@@ -37,8 +38,8 @@ def test_add_second_address(acfactory) -> None:
|
||||
with pytest.raises(JsonRpcError):
|
||||
account.set_config(option, "1")
|
||||
|
||||
with pytest.raises(JsonRpcError):
|
||||
account.set_config("show_emails", "0")
|
||||
# show_emails does not matter for multi-relay, can be set to anything
|
||||
account.set_config("show_emails", "0")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["mvbox_move", "only_fetch_mvbox"])
|
||||
@@ -57,8 +58,8 @@ def test_no_second_transport_with_mvbox(acfactory, key) -> None:
|
||||
account.add_transport_from_qr(qr)
|
||||
|
||||
|
||||
def test_no_second_transport_without_classic_emails(acfactory) -> None:
|
||||
"""Test that second transport cannot be configured if classic emails are not fetched."""
|
||||
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
|
||||
|
||||
@@ -67,8 +68,7 @@ def test_no_second_transport_without_classic_emails(acfactory) -> None:
|
||||
qr = acfactory.get_account_qr()
|
||||
account.set_config("show_emails", "0")
|
||||
|
||||
with pytest.raises(JsonRpcError):
|
||||
account.add_transport_from_qr(qr)
|
||||
account.add_transport_from_qr(qr)
|
||||
|
||||
|
||||
def test_change_address(acfactory) -> None:
|
||||
@@ -120,6 +120,33 @@ def test_change_address(acfactory) -> None:
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_chatmail", ["0", "1"])
|
||||
def test_mvbox_move_first_transport(acfactory, is_chatmail) -> None:
|
||||
"""Test that mvbox_move is disabled by default even for non-chatmail accounts.
|
||||
@@ -161,6 +188,13 @@ def test_reconfigure_transport(acfactory) -> None:
|
||||
|
||||
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()
|
||||
@@ -169,11 +203,13 @@ def test_transport_synchronization(acfactory, log) -> None:
|
||||
|
||||
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
|
||||
|
||||
@@ -183,15 +219,24 @@ def test_transport_synchronization(acfactory, log) -> None:
|
||||
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
|
||||
@@ -201,3 +246,143 @@ def test_transport_synchronization(acfactory, log) -> None:
|
||||
|
||||
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, log) -> 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)
|
||||
|
||||
log.section("Alice sets up second 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!"
|
||||
|
||||
log.section("Alice removes the primary relay")
|
||||
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!"
|
||||
assert msg2.chat.get_basic_snapshot().chat_type == ChatType.SINGLE
|
||||
assert msg2.chat == alice.create_chat(bob)
|
||||
|
||||
@@ -167,11 +167,16 @@ def test_qr_securejoin_broadcast(acfactory, all_devices_online):
|
||||
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:
|
||||
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.pop(0).get_snapshot()
|
||||
@@ -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:
|
||||
@@ -276,6 +273,9 @@ def test_chat(acfactory) -> None:
|
||||
assert group.get_messages()
|
||||
group.get_fresh_message_count()
|
||||
group.mark_noticed()
|
||||
assert group.get_fresh_message_count() == 0
|
||||
group.mark_fresh()
|
||||
assert group.get_fresh_message_count() > 0
|
||||
assert group.get_contacts()
|
||||
assert group.get_past_contacts() == []
|
||||
group.remove_contact(alice_contact_bob)
|
||||
@@ -336,26 +336,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 +374,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()
|
||||
@@ -508,6 +540,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)
|
||||
|
||||
@@ -539,6 +668,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()
|
||||
|
||||
@@ -591,60 +738,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
|
||||
@@ -671,14 +764,159 @@ 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 snapshot.download_state == DownloadState.DONE:
|
||||
n_done += 1
|
||||
# Work around lost and reordered pre-messages.
|
||||
assert n_done <= 1
|
||||
else:
|
||||
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):
|
||||
"""
|
||||
Test that seen status is synchronized for contact request messages
|
||||
@@ -702,6 +940,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.
|
||||
@@ -721,6 +1000,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()
|
||||
@@ -733,7 +1015,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)
|
||||
@@ -746,7 +1028,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):
|
||||
@@ -924,6 +1206,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)
|
||||
|
||||
@@ -1012,3 +1318,67 @@ def test_message_exists(acfactory):
|
||||
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.32.0"
|
||||
version = "2.48.0-dev"
|
||||
description = "DeltaChat JSON-RPC server"
|
||||
edition = "2021"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "index.d.ts",
|
||||
"version": "2.32.0"
|
||||
"version": "2.48.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;
|
||||
|
||||
17
deny.toml
17
deny.toml
@@ -12,6 +12,18 @@ 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",
|
||||
|
||||
# rustls-webpki v0.102.8
|
||||
# We cannot upgrade to >=0.103.10 because
|
||||
# it is a transitive dependency of iroh 0.35.0
|
||||
# which depends on ^0.102.
|
||||
# <https://rustsec.org/advisories/RUSTSEC-2026-0049>
|
||||
"RUSTSEC-2026-0049",
|
||||
]
|
||||
|
||||
[bans]
|
||||
@@ -22,21 +34,22 @@ ignore = [
|
||||
skip = [
|
||||
{ name = "async-channel", version = "1.9.0" },
|
||||
{ name = "bitflags", version = "1.3.2" },
|
||||
{ name = "constant_time_eq", version = "0.3.1" },
|
||||
{ name = "derive_more-impl", version = "1.0.0" },
|
||||
{ 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" },
|
||||
{ name = "rand_core", version = "0.6.4" },
|
||||
{ name = "rand", version = "0.8.5" },
|
||||
{ name = "rustix", version = "0.38.44" },
|
||||
{ name = "rustls-webpki", version = "0.102.8" },
|
||||
{ name = "serdect", version = "0.2.0" },
|
||||
{ name = "socket2", version = "0.5.9" },
|
||||
{ name = "spin", version = "0.9.8" },
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
AEAP MVP
|
||||
========
|
||||
|
||||
Changes to the UIs
|
||||
------------------
|
||||
|
||||
- The secondary self addresses (see below) are shown in the UI, but not editable.
|
||||
|
||||
- When the user changed the email address in the configure screen, show a dialog to the user, either directly explaining things or with a link to the FAQ (see "Other" below)
|
||||
|
||||
Changes in the core
|
||||
-------------------
|
||||
|
||||
- [x] We have one primary self address and any number of secondary self addresses. `is_self_addr()` checks all of them.
|
||||
|
||||
- [x] If the user does a reconfigure and changes the email address, the previous address is added as a secondary self address.
|
||||
|
||||
- don't forget to deduplicate secondary self addresses in case the user switches back and forth between addresses).
|
||||
|
||||
- The key stays the same.
|
||||
|
||||
- [x] No changes for 1:1 chats, there simply is a new one. (This works since, contrary to group messages, messages sent to a 1:1 chat are not assigned to the group chat but always to the 1:1 chat with the sender. So it's not a problem that the new messages might be put into the old chat if they are a reply to a message there.)
|
||||
|
||||
- [ ] When sending a message: If any of the secondary self addrs is in the chat's member list, remove it locally (because we just transitioned away from it). We add a log message for this (alternatively, a system message in the chat would be more visible).
|
||||
|
||||
- [x] ([#3385](https://github.com/deltachat/deltachat-core-rust/pull/3385)) When receiving a message: If the key exists, but belongs to another address (we may want to benchmark this)
|
||||
AND there is a `Chat-Version` header\
|
||||
AND the message is signed correctly
|
||||
AND the From address is (also) in the encrypted (and therefore signed) headers <sup>[[1]](#myfootnote1)</sup>\
|
||||
AND the message timestamp is newer than the contact's `lastseen` (to prevent changing the address back when messages arrive out of order) (this condition is not that important since we will have eventual consistency even without it):
|
||||
|
||||
Replace the contact in _all_ groups, possibly deduplicate the members list, and add a system message to all of these chats.
|
||||
|
||||
- Note that we can't simply compare the keys byte-by-byte, since the UID may have changed, or the sender may have rotated the key and signed the new key with the old one.
|
||||
|
||||
<a name="myfootnote1">[1]</a>: Without this check, an attacker could replay a message from Alice to Bob. Then Bob's device would do an AEAP transition from Alice's to the attacker's address, allowing for easier phishing.
|
||||
|
||||
<details>
|
||||
<summary>More details about this</summary>
|
||||
Suppose Alice sends a message to Evil (or to a group with both Evil and Bob). Evil then forwards the message to Bob, changing the From and To headers (and if necessary Message-Id) and replacing `addr=alice@example.org;` in the autocrypt header with `addr=evil@example.org;`.
|
||||
|
||||
Then Bob's device sees that there is a message which is signed by Alice's key and comes from Evil's address and would do the AEAP transition, i.e. replace Alice with Evil in all groups and show a message "Alice changed their address from alice@example.org to evil@example.org". Disadvantages for Evil are that Bob's message will be shown on Alice's device, possibly creating confusion/suspicion, and that the usual "Setup changed for..." message will be shown the next time Evil sends a message (because Evil doesn't know Alice's private key).
|
||||
|
||||
Possible mitigations:
|
||||
- if we make the AEAP device message sth. like "Automatically removed alice@example.org and added evil@example.org", then this will create more suspicion, making the phishing harder (we didn't talk about what what the wording should be at all yet).
|
||||
- Add something similar to replay protection to our Autocrypt implementation. This could be done e.g. by adding a second `From` header to the protected headers. If it's present, the receiver then requires it to be the same as the outer `From`, and if it's not present, we don't do AEAP --> **That's what we implemented**
|
||||
|
||||
Note that usually a mail is signed by a key that has a UID matching the from address.
|
||||
|
||||
That's not mandatory for Autocrypt (and in fact, we just keep the old UID when changing the self address, so with AEAP the UID will actually be different than the from address sometimes)
|
||||
|
||||
https://autocrypt.org/level1.html#openpgp-based-key-data says:
|
||||
> The content of the user id packet is only decorative
|
||||
|
||||
</details>
|
||||
|
||||
### Notes:
|
||||
|
||||
- We treat protected and non-protected chats the same
|
||||
- We leave the aeap transition statement away since it seems not to be needed, makes things harder on the sending side, wastes some network traffic, and is worse for privacy (since more people know what old addresses you had).
|
||||
- As soon as we encrypt read receipts, sending a read receipt will be enough to tell a lot of people that you transitioned
|
||||
- AEAP will make the problem of inconsistent group state worse, both because it doesn't work if the message is unencrypted (even if the design allowed it, it would be problematic security-wise) and because some chat partners may have gotten the transition and some not. We should do something against this at some point in the future, like asking the user whether they want to add/remove the members to restore consistent group state.
|
||||
|
||||
#### Downsides of this design:
|
||||
- Inconsistent group state: Suppose Alice does an AEAP transition and sends a 1:1 message to Bob, so Bob rewrites Alice's contact. Alice, Bob and Charlie are together in a group. Before Alice writes to this group, Bob and Charlie will have different membership lists, and Bob will send messages to Alice's new address, while Charlie will send them to her old address.
|
||||
|
||||
#### Upsides:
|
||||
- With this approach, it's easy to switch to a model where the info about the transition is encoded in the PGP key. Since the key is gossiped, the information about the transition will spread virally.
|
||||
- Faster transition: If you send a message to e.g. "Delta Chat Dev", all members of the "sub-group" "delta android" will know of your transition.
|
||||
|
||||
### Alternatives and old discussions/plans:
|
||||
|
||||
- Change the contact instead of rewriting the group member lists. This seems to call for more trouble since we will end up with multiple contacts having the same email address.
|
||||
|
||||
- If needed, we could add a header a) indicating that the sender did an address transition or b) listing all the secondary (old) addresses. For now, there is no big enough benefit to warrant introducing another header and its processing on the receiver side (including all the necessary checks and handling of error cases). Instead, we only check for the `Chat-Version` header to prevent accidental transitions when an MUA user sends a message from another email address with the same key.
|
||||
|
||||
- The condition for a transition temporarily was:
|
||||
|
||||
> When receiving a message: If we are going to assign a message to a chat, but the sender is not a member of this chat\
|
||||
> AND the signing key is the same as the direct (non-gossiped) key of one of the chat members\
|
||||
> AND ...
|
||||
|
||||
However, this would mean that in 1:1 messages can't trigger a transition, since we don't assign private messages to the parent chat, but always to the 1:1 chat with the sender.
|
||||
|
||||
<details>
|
||||
<summary>Some previous state of the discussion, which temporarily lived in an issue description</summary>
|
||||
Summarizing the discussions from https://github.com/deltachat/deltachat-core-rust/pull/2896, mostly quoting @hpk42:
|
||||
|
||||
1. (DONE) At the time of configure we push the current primary to become a secondary.
|
||||
|
||||
2. When a message is sent out to a chat, and the message is encrypted, and we have secondary addresses, then we
|
||||
a) add a protected "AEAP-Replacement" header that contains all secondary addresses
|
||||
b) if any of the secondary addresses is in the chat's member list, we remove it and leave a system message that we did so
|
||||
3. When an encrypted message with a replacement header is received, replace the e-mail address of all secondary contacts (if they exist) with the new primary and drop a sysmessage in all chats the secondary is member off. This might (in edge cases) result in chats that have two or more contacts with the same e-mail address. We might ignore this for a first release and just log a warning. Let's maybe not get hung up on this case before everything else works.
|
||||
|
||||
Notes:
|
||||
- for now we will send out aeap replacement headers forever, there is no termination condition other than lack of secondary addresses. I think that's fine for now. Later on we might introduce options to remove secondary addresses but i wouldn't do this for a first release/PR.
|
||||
- the design is resilient against changing e-mail providers from A to B to C and then back to A, with partially updated chats and diverging views from recipients/contacts on this transition. In the end, you will have a primary and some secondaries, and when you start sending out messages everybody will eventually synchronize when they receive the current state of primaries/secondaries.
|
||||
- of course on incoming message for need to check for each stated secondary address in the replacement header that it uses the same signature as the signature we verified as valid with the incoming message **--> Also we have to somehow make sure that the signing key was not just gossiped from some random other person in some group.**
|
||||
- there are no extra flags/columns in the database needed (i hope)
|
||||
|
||||
#### Downsides of the chosen approach:
|
||||
- Inconsistent group state: Suppose Alice does an AEAP transition and sends a 1:1 message to Bob, so Bob rewrites Alice's contact. Alice, Bob and Charlie are together in a group. Before Alice writes to this group, Bob and Charlie will have different membership lists, and Bob will send messages to Alice's new address, while Charlie will send them to her old address.
|
||||
- There will be multiple contacts with the same address in the database. We will have to do something against this at some point.
|
||||
|
||||
The most obvious alternative would be to create a new contact with the new address and replace the old contact in the groups.
|
||||
|
||||
#### Upsides:
|
||||
- With this approach, it's easier to switch to a model where the info about the transition is encoded in the PGP key. Since the key is gossiped, the information about the transition will spread virally.
|
||||
- (Also, less important: Slightly faster transition: If you send a message to e.g. "Delta Chat Dev", all members of the "sub-group" "delta android" will know of your transition.)
|
||||
- It's easier to implement (if too many problems turn up, we can still switch to another approach and didn't waste that much development time.)
|
||||
|
||||
[full messages](https://github.com/deltachat/deltachat-core-rust/pull/2896#discussion_r852002161)
|
||||
|
||||
_end of the previous state of the discussion_
|
||||
|
||||
</details>
|
||||
|
||||
Other
|
||||
-----
|
||||
|
||||
- The user is responsible that messages to the old address arrive at the new address, for example by configuring the old provider to forward all emails to the new one.
|
||||
|
||||
Notes during implementing
|
||||
========================
|
||||
|
||||
- As far as I understand the code, unencrypted messages are unsigned. So, the transition only works if both sides have the other side's key.
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "deltachat"
|
||||
version = "2.32.0"
|
||||
version = "2.48.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"
|
||||
|
||||
@@ -553,17 +553,6 @@ class Account:
|
||||
def imex(self, path: str, imex_cmd: int, passphrase: Optional[str] = None) -> None:
|
||||
lib.dc_imex(self._dc_context, imex_cmd, as_dc_charpointer(path), as_dc_charpointer(passphrase))
|
||||
|
||||
def initiate_key_transfer(self) -> str:
|
||||
"""return setup code after a Autocrypt setup message
|
||||
has been successfully sent to our own e-mail address ("self-sent message").
|
||||
If sending out was unsuccessful, a RuntimeError is raised.
|
||||
"""
|
||||
self.check_is_configured()
|
||||
res = lib.dc_initiate_key_transfer(self._dc_context)
|
||||
if res == ffi.NULL:
|
||||
raise RuntimeError("could not send out autocrypt setup message")
|
||||
return from_dc_charpointer(res)
|
||||
|
||||
def get_setup_contact_qr(self) -> str:
|
||||
"""get/create Setup-Contact QR Code as ascii-string.
|
||||
|
||||
|
||||
@@ -167,14 +167,6 @@ class Message:
|
||||
"""return True if this message is a system/info message."""
|
||||
return bool(lib.dc_msg_is_info(self._dc_msg))
|
||||
|
||||
def is_setup_message(self):
|
||||
"""return True if this message is a setup message."""
|
||||
return lib.dc_msg_is_setupmessage(self._dc_msg)
|
||||
|
||||
def get_setupcodebegin(self) -> str:
|
||||
"""return the first characters of a setup code in a setup message."""
|
||||
return from_dc_charpointer(lib.dc_msg_get_setupcodebegin(self._dc_msg))
|
||||
|
||||
def is_encrypted(self):
|
||||
"""return True if this message was encrypted."""
|
||||
return bool(lib.dc_msg_get_showpadlock(self._dc_msg))
|
||||
@@ -198,12 +190,6 @@ class Message:
|
||||
"""Get a message summary as a single line of text. Typically used for notifications."""
|
||||
return from_dc_charpointer(lib.dc_msg_get_summarytext(self._dc_msg, width))
|
||||
|
||||
def continue_key_transfer(self, setup_code):
|
||||
"""extract key and use it as primary key for this account."""
|
||||
res = lib.dc_continue_key_transfer(self.account._dc_context, self.id, as_dc_charpointer(setup_code))
|
||||
if res == 0:
|
||||
raise ValueError("Importing the key from Autocrypt Setup Message failed")
|
||||
|
||||
@props.with_doc
|
||||
def time_sent(self):
|
||||
"""UTC time when the message was sent.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
2025-12-04
|
||||
2026-03-24
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
21
spec.md
21
spec.md
@@ -39,9 +39,24 @@ Messages SHOULD be encrypted by the
|
||||
[Autocrypt](https://autocrypt.org/level1.html) standard;
|
||||
`prefer-encrypt=mutual` MAY be set by default.
|
||||
|
||||
Meta data (at least the subject and all chat-headers) SHOULD be encrypted
|
||||
by the [Protected Headers](https://tools.ietf.org/id/draft-autocrypt-lamps-protected-headers-02.html) standard.
|
||||
|
||||
Meta data SHOULD be encrypted
|
||||
by the [Header Protection](https://www.rfc-editor.org/rfc/rfc9788.html) standard
|
||||
with the following [Header Confidentiality Policy](https://www.rfc-editor.org/rfc/rfc9788.html#name-header-confidentiality-poli):
|
||||
```
|
||||
hcp_chat(name, val_in) → val_out:
|
||||
if lower(name) is 'from':
|
||||
assert that val_in is an RFC 5322 mailbox
|
||||
return the RFC 5322 addr-spec part of val_in
|
||||
else if lower(name) is 'to':
|
||||
return '"hidden-recipients": ;'
|
||||
else if lower(name) is 'date':
|
||||
return the UTC form of a random date within the last 7 days
|
||||
else if lower(name) is 'subject':
|
||||
return '[...]'
|
||||
else if lower(name) is in ['message-id', 'chat-is-post-message']:
|
||||
return val_in
|
||||
return null
|
||||
```
|
||||
|
||||
# Outgoing messages
|
||||
|
||||
|
||||
107
src/accounts.rs
107
src/accounts.rs
@@ -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?);
|
||||
@@ -655,13 +686,27 @@ impl Config {
|
||||
file.write_all(toml::to_string_pretty(&self.inner)?.as_bytes())
|
||||
.await
|
||||
.context("failed to write a tmp config")?;
|
||||
file.sync_data()
|
||||
|
||||
// We use `sync_all()` and not `sync_data()` here.
|
||||
// This translates to `fsync()` instead of `fdatasync()`.
|
||||
// `fdatasync()` may be insufficient for newely created files
|
||||
// and may not even synchronize the file size on some operating systems,
|
||||
// resulting in a truncated file.
|
||||
file.sync_all()
|
||||
.await
|
||||
.context("failed to sync a tmp config")?;
|
||||
drop(file);
|
||||
fs::rename(&tmp_path, &self.file)
|
||||
.await
|
||||
.context("failed to rename config")?;
|
||||
// Sync the rename().
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let parent = self.file.parent().context("No parent directory")?;
|
||||
let parent_file = fs::File::open(parent).await?;
|
||||
parent_file.sync_all().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -727,6 +772,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;
|
||||
@@ -816,6 +862,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,
|
||||
@@ -887,6 +934,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();
|
||||
@@ -1182,13 +1249,11 @@ mod tests {
|
||||
let account1 = accounts.get_account(1).context("failed to get account 1")?;
|
||||
let account2 = accounts.get_account(2).context("failed to get account 2")?;
|
||||
|
||||
assert_eq!(stock_str::no_messages(&account1).await, "No messages.");
|
||||
assert_eq!(stock_str::no_messages(&account2).await, "No messages.");
|
||||
account1
|
||||
.set_stock_translation(StockMessage::NoMessages, "foobar".to_string())
|
||||
.await?;
|
||||
assert_eq!(stock_str::no_messages(&account1).await, "foobar");
|
||||
assert_eq!(stock_str::no_messages(&account2).await, "foobar");
|
||||
assert_eq!(stock_str::no_messages(&account1), "No messages.");
|
||||
assert_eq!(stock_str::no_messages(&account2), "No messages.");
|
||||
account1.set_stock_translation(StockMessage::NoMessages, "foobar".to_string())?;
|
||||
assert_eq!(stock_str::no_messages(&account1), "foobar");
|
||||
assert_eq!(stock_str::no_messages(&account2), "foobar");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -798,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(())
|
||||
}
|
||||
|
||||
324
src/calls.rs
324
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, 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,12 @@ 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());
|
||||
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());
|
||||
self.update_text(context, &format!("{outgoing_call_str}\n{duration}"))
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -123,6 +128,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 +174,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 +196,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,12 +205,15 @@ impl Context {
|
||||
);
|
||||
ensure!(!chat.is_self_talk(), "Cannot call self");
|
||||
|
||||
let outgoing_call_str = stock_str::outgoing_call(self, has_video_initially);
|
||||
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;
|
||||
@@ -229,6 +247,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 {
|
||||
@@ -245,6 +264,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(())
|
||||
@@ -263,10 +283,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);
|
||||
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);
|
||||
call.update_text(self, &canceled_call_str).await?;
|
||||
}
|
||||
} else {
|
||||
call.mark_as_ended(self).await?;
|
||||
@@ -308,10 +331,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);
|
||||
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);
|
||||
call.update_text(&context, &canceled_call_str).await?;
|
||||
}
|
||||
context.emit_msgs_changed(call.msg.chat_id, call_id);
|
||||
context.emit_event(EventType::CallEnded {
|
||||
@@ -336,38 +361,42 @@ 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);
|
||||
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());
|
||||
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();
|
||||
@@ -378,7 +407,8 @@ impl Context {
|
||||
));
|
||||
}
|
||||
} else {
|
||||
call.update_text(self, "Outgoing call").await?;
|
||||
let outgoing_call_str = stock_str::outgoing_call(self, call.has_video_initially());
|
||||
call.update_text(self, &outgoing_call_str).await?;
|
||||
self.emit_msgs_changed(call.msg.chat_id, call_id);
|
||||
}
|
||||
} else {
|
||||
@@ -400,6 +430,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
|
||||
@@ -428,19 +459,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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
call.update_text(self, &declined_call_str).await?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -496,19 +531,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 {
|
||||
@@ -606,33 +628,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=`
|
||||
@@ -642,20 +638,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
|
||||
@@ -663,25 +746,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.
|
||||
@@ -695,11 +771,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(())
|
||||
}
|
||||
|
||||
733
src/chat.rs
733
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;
|
||||
@@ -805,7 +806,7 @@ async fn test_self_talk() -> Result<()> {
|
||||
assert!(chat.visibility == ChatVisibility::Normal);
|
||||
assert!(!chat.is_device_talk());
|
||||
assert!(chat.can_send(&t).await?);
|
||||
assert_eq!(chat.name, stock_str::saved_messages(&t).await);
|
||||
assert_eq!(chat.name, stock_str::saved_messages(&t));
|
||||
assert!(chat.get_profile_image(&t).await?.is_some());
|
||||
|
||||
let msg_id = send_text_msg(&t, chat.id, "foo self".to_string()).await?;
|
||||
@@ -865,7 +866,6 @@ async fn test_add_device_msg_unlabelled() {
|
||||
assert_eq!(msg1.from_id, ContactId::DEVICE);
|
||||
assert_eq!(msg1.to_id, ContactId::SELF);
|
||||
assert!(!msg1.is_info());
|
||||
assert!(!msg1.is_setupmessage());
|
||||
|
||||
let msg2 = message::Message::load_from_db(&t, msg2_id.unwrap()).await;
|
||||
assert!(msg2.is_ok());
|
||||
@@ -898,7 +898,6 @@ async fn test_add_device_msg_labelled() -> Result<()> {
|
||||
assert_eq!(msg1.from_id, ContactId::DEVICE);
|
||||
assert_eq!(msg1.to_id, ContactId::SELF);
|
||||
assert!(!msg1.is_info());
|
||||
assert!(!msg1.is_setupmessage());
|
||||
|
||||
// check device chat
|
||||
let chat_id = msg1.chat_id;
|
||||
@@ -912,7 +911,7 @@ async fn test_add_device_msg_labelled() -> Result<()> {
|
||||
assert!(!chat.can_send(&t).await?);
|
||||
assert!(chat.why_cant_send(&t).await? == Some(CantSendReason::DeviceChat));
|
||||
|
||||
assert_eq!(chat.name, stock_str::device_messages(&t).await);
|
||||
assert_eq!(chat.name, stock_str::device_messages(&t));
|
||||
let device_msg_icon = chat.get_profile_image(&t).await?.unwrap();
|
||||
assert_eq!(
|
||||
device_msg_icon.metadata()?.len(),
|
||||
@@ -1240,6 +1239,144 @@ 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_markfresh_chat() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
// alice sends a message to Bob
|
||||
let alice_chat = alice.create_chat(bob).await;
|
||||
let sent_msg1 = alice.send_text(alice_chat.id, "hi bob!").await;
|
||||
|
||||
// bob received the message, fresh count is 1
|
||||
let bob_msg1 = bob.recv_msg(&sent_msg1).await;
|
||||
let bob_chat_id = bob_msg1.chat_id;
|
||||
bob_chat_id.accept(bob).await?;
|
||||
assert_eq!(bob_msg1.state, MessageState::InFresh);
|
||||
assert_eq!(bob_chat_id.get_fresh_msg_cnt(bob).await?, 1);
|
||||
assert_eq!(bob.get_fresh_msgs().await?.len(), 1);
|
||||
|
||||
// alice sends another message to bob, fresh count is 2
|
||||
let sent_msg2 = alice.send_text(alice_chat.id, "howdy?").await;
|
||||
let bob_msg2 = bob.recv_msg(&sent_msg2).await;
|
||||
let bob_msg1 = Message::load_from_db(bob, bob_msg1.id).await?;
|
||||
assert_eq!(bob_msg1.state, MessageState::InFresh);
|
||||
assert_eq!(bob_msg2.state, MessageState::InFresh);
|
||||
assert_eq!(bob_chat_id.get_fresh_msg_cnt(bob).await?, 2);
|
||||
assert_eq!(bob.get_fresh_msgs().await?.len(), 2);
|
||||
|
||||
// bob marks the chat as noticed, messages are no longer fresh, fresh count is 0
|
||||
marknoticed_chat(bob, bob_chat_id).await?;
|
||||
let bob_msg1 = Message::load_from_db(bob, bob_msg1.id).await?;
|
||||
let bob_msg2 = Message::load_from_db(bob, bob_msg2.id).await?;
|
||||
assert_ne!(bob_msg1.state, MessageState::InFresh);
|
||||
assert_ne!(bob_msg2.state, MessageState::InFresh);
|
||||
assert_eq!(bob_chat_id.get_fresh_msg_cnt(bob).await?, 0);
|
||||
assert_eq!(bob.get_fresh_msgs().await?.len(), 0);
|
||||
|
||||
// bob marks the chat as fresh again, fresh count is 1 again
|
||||
markfresh_chat(bob, bob_chat_id).await?;
|
||||
let bob_msg1 = Message::load_from_db(bob, bob_msg1.id).await?;
|
||||
let bob_msg2 = Message::load_from_db(bob, bob_msg2.id).await?;
|
||||
assert_ne!(bob_msg1.state, MessageState::InFresh);
|
||||
assert_eq!(bob_msg2.state, MessageState::InFresh);
|
||||
assert_eq!(bob_chat_id.get_fresh_msg_cnt(bob).await?, 1);
|
||||
assert_eq!(bob.get_fresh_msgs().await?.len(), 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_archive_fresh_msgs() -> Result<()> {
|
||||
let t = TestContext::new_alice().await;
|
||||
@@ -2550,7 +2687,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(())
|
||||
}
|
||||
@@ -2640,27 +2777,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");
|
||||
@@ -2862,6 +2996,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
|
||||
@@ -2910,7 +3183,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");
|
||||
@@ -2927,7 +3200,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?;
|
||||
@@ -2948,6 +3221,206 @@ 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:/\\|?*".repeat(50),
|
||||
"multiple\nline\n\nbreaks\n\n\r\n.",
|
||||
"",
|
||||
"ä ẟ 😂",
|
||||
] {
|
||||
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)]
|
||||
@@ -2978,14 +3451,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;
|
||||
@@ -3116,7 +3592,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)
|
||||
@@ -3227,6 +3703,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.");
|
||||
@@ -3257,16 +3738,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,
|
||||
@@ -3282,7 +3760,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;
|
||||
|
||||
@@ -3311,15 +3789,15 @@ 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, "I left the group.");
|
||||
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;
|
||||
|
||||
assert_eq!(rcvd.chat_id, bob1_hello.chat_id);
|
||||
assert!(rcvd.is_info());
|
||||
assert_eq!(rcvd.get_info_type(), SystemMessage::MemberRemovedFromGroup);
|
||||
assert_eq!(rcvd.text, stock_str::msg_you_left_broadcast(bob1).await);
|
||||
assert_eq!(rcvd.text, stock_str::msg_you_left_broadcast(bob1));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -3379,7 +3857,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);
|
||||
@@ -3429,6 +3907,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'", ())
|
||||
@@ -3439,14 +3918,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(())
|
||||
}
|
||||
@@ -3481,7 +3966,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?;
|
||||
@@ -3495,7 +3980,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();
|
||||
@@ -3515,6 +4000,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(
|
||||
@@ -3538,6 +4024,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")
|
||||
@@ -3603,15 +4090,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"
|
||||
);
|
||||
@@ -3619,13 +4106,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"
|
||||
);
|
||||
@@ -3633,13 +4120,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\
|
||||
@@ -4297,6 +4784,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;
|
||||
@@ -4361,7 +4852,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)
|
||||
@@ -4431,6 +4922,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
|
||||
@@ -5240,6 +5747,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()
|
||||
{
|
||||
@@ -418,7 +417,7 @@ impl Chatlist {
|
||||
Summary::new_with_reaction_details(context, &lastmsg, chat, lastcontact.as_ref()).await
|
||||
} else {
|
||||
Ok(Summary {
|
||||
text: stock_str::no_messages(context).await,
|
||||
text: stock_str::no_messages(context),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
@@ -604,7 +648,6 @@ mod tests {
|
||||
assert_eq!(chats.len(), 0);
|
||||
|
||||
t.set_stock_translation(StockMessage::SavedMessages, "test-1234-save".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
let chats = Chatlist::try_load(&t, 0, Some("t-1234-s"), None)
|
||||
.await
|
||||
@@ -612,7 +655,6 @@ mod tests {
|
||||
assert_eq!(chats.len(), 1);
|
||||
|
||||
t.set_stock_translation(StockMessage::DeviceMessages, "test-5678-babbel".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
let chats = Chatlist::try_load(&t, 0, Some("t-5678-b"), None)
|
||||
.await
|
||||
@@ -797,6 +839,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)
|
||||
}
|
||||
|
||||
245
src/config.rs
245
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;
|
||||
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.
|
||||
@@ -176,11 +175,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 +194,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.
|
||||
@@ -281,9 +271,6 @@ pub enum Config {
|
||||
/// Configured folder for chat messages.
|
||||
ConfiguredMvboxFolder,
|
||||
|
||||
/// Configured "Trash" folder.
|
||||
ConfiguredTrashFolder,
|
||||
|
||||
/// Unix timestamp of the last successful configuration.
|
||||
ConfiguredTimestamp,
|
||||
|
||||
@@ -306,10 +293,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 +328,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 +338,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 +432,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 {
|
||||
@@ -509,7 +508,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?,
|
||||
@@ -592,8 +591,7 @@ impl Context {
|
||||
.get_config(key)
|
||||
.await?
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.map(|x| x != 0)
|
||||
.unwrap_or_default())
|
||||
.is_some_and(|x| x != 0))
|
||||
}
|
||||
|
||||
/// Returns true if movebox ("DeltaChat" folder) should be watched.
|
||||
@@ -610,12 +608,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? {
|
||||
@@ -692,7 +684,6 @@ impl Context {
|
||||
| Config::MdnsEnabled
|
||||
| Config::MvboxMove
|
||||
| Config::OnlyFetchMvbox
|
||||
| Config::DeleteToTrash
|
||||
| Config::Configured
|
||||
| Config::Bot
|
||||
| Config::NotifyAboutWrongPw
|
||||
@@ -716,12 +707,7 @@ impl Context {
|
||||
Self::check_config(key, value)?;
|
||||
|
||||
let n_transports = self.count_transports().await?;
|
||||
if n_transports > 1
|
||||
&& matches!(
|
||||
key,
|
||||
Config::MvboxMove | Config::OnlyFetchMvbox | Config::ShowEmails
|
||||
)
|
||||
{
|
||||
if n_transports > 1 && matches!(key, Config::MvboxMove | Config::OnlyFetchMvbox) {
|
||||
bail!("Cannot reconfigure {key} when multiple transports are configured");
|
||||
}
|
||||
|
||||
@@ -819,50 +805,59 @@ impl Context {
|
||||
self,
|
||||
"Creating a pseudo configured account which will not be able to send or receive messages. Only meant for tests!"
|
||||
);
|
||||
self.sql
|
||||
.execute(
|
||||
"INSERT INTO transports (addr, entered_param, configured_param) VALUES (?, ?, ?)",
|
||||
(
|
||||
addr,
|
||||
serde_json::to_string(&EnteredLoginParam::default())?,
|
||||
format!(r#"{{"addr":"{addr}","imap":[],"imap_user":"","imap_password":"","smtp":[],"smtp_user":"","smtp_password":"","certificate_checks":"Automatic","oauth2":false}}"#)
|
||||
),
|
||||
)
|
||||
.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=?, is_published=1 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;
|
||||
}
|
||||
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,),
|
||||
)?;
|
||||
|
||||
// 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?;
|
||||
self.sql.uncache_raw_config("configured_addr").await;
|
||||
}
|
||||
_ => {
|
||||
self.sql.set_raw_config(key.as_ref(), value).await?;
|
||||
@@ -892,7 +887,7 @@ impl Context {
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
self.scheduler.interrupt_inbox().await;
|
||||
self.scheduler.interrupt_smtp().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -956,17 +951,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))
|
||||
@@ -975,24 +960,59 @@ 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();
|
||||
self.sql
|
||||
.query_map_vec(
|
||||
"SELECT addr FROM transports ORDER BY add_timestamp DESC, id DESC",
|
||||
(),
|
||||
|row| {
|
||||
let addr: String = row.get(0)?;
|
||||
Ok(addr)
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
Ok(primary_addrs.chain(secondary_addrs).collect())
|
||||
/// Returns all published self addresses, newest first.
|
||||
/// See `[Context::set_transport_unpublished]`
|
||||
pub(crate) async fn get_published_self_addrs(&self) -> Result<Vec<String>> {
|
||||
self.sql
|
||||
.query_map_vec(
|
||||
"SELECT addr FROM transports WHERE is_published=1 ORDER BY add_timestamp DESC, id 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 all published secondary self addresses.
|
||||
/// See `[Context::set_transport_unpublished]`
|
||||
pub(crate) async fn get_published_secondary_self_addrs(&self) -> Result<Vec<String>> {
|
||||
self.sql
|
||||
.query_map_vec(
|
||||
"SELECT addr FROM transports
|
||||
WHERE is_published
|
||||
AND addr NOT IN (SELECT value FROM config WHERE keyname='configured_addr')
|
||||
ORDER BY add_timestamp DESC, id DESC",
|
||||
(),
|
||||
|row| {
|
||||
let addr: String = row.get(0)?;
|
||||
Ok(addr)
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns the primary self address.
|
||||
@@ -1015,5 +1035,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;
|
||||
@@ -268,8 +246,7 @@ async fn test_sync() -> Result<()> {
|
||||
alice1
|
||||
.get_config(Config::Selfavatar)
|
||||
.await?
|
||||
.filter(|path| path.ends_with(".png"))
|
||||
.is_some()
|
||||
.is_some_and(|path| path.ends_with(".png"))
|
||||
);
|
||||
alice0.set_config(Config::Selfavatar, None).await?;
|
||||
sync(&alice0, &alice1).await;
|
||||
@@ -290,7 +267,7 @@ async fn test_no_sync_on_self_sent_msg() -> Result<()> {
|
||||
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;
|
||||
@@ -314,7 +291,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?;
|
||||
@@ -327,16 +304,14 @@ async fn test_no_sync_on_self_sent_msg() -> Result<()> {
|
||||
alice1
|
||||
.get_config(Config::Selfavatar)
|
||||
.await?
|
||||
.filter(|path| path.ends_with(".jpg"))
|
||||
.is_some()
|
||||
.is_some_and(|path| path.ends_with(".jpg"))
|
||||
);
|
||||
sync(alice1, alice0).await;
|
||||
assert!(
|
||||
alice0
|
||||
.get_config(Config::Selfavatar)
|
||||
.await?
|
||||
.filter(|path| path.ends_with(".jpg"))
|
||||
.is_some()
|
||||
.is_some_and(|path| path.ends_with(".jpg"))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
||||
152
src/configure.rs
152
src/configure.rs
@@ -23,13 +23,13 @@ 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::warn;
|
||||
use crate::login_param::EnteredCertificateChecks;
|
||||
pub use crate::login_param::EnteredLoginParam;
|
||||
use crate::login_param::{EnteredCertificateChecks, TransportListEntry};
|
||||
use crate::message::Message;
|
||||
use crate::net::proxy::ProxyConfig;
|
||||
use crate::oauth2::get_oauth2_addr;
|
||||
@@ -45,6 +45,10 @@ use crate::transport::{
|
||||
use crate::{EventType, stock_str};
|
||||
use crate::{chat, provider};
|
||||
|
||||
/// 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) => {
|
||||
assert!(
|
||||
@@ -106,6 +110,7 @@ impl Context {
|
||||
/// from a server encoded in a QR code.
|
||||
/// - [Self::list_transports()] to get a list of all configured transports.
|
||||
/// - [Self::delete_transport()] to remove a transport.
|
||||
/// - [Self::set_transport_unpublished()] to set whether contacts see this transport.
|
||||
pub async fn add_or_update_transport(&self, param: &mut EnteredLoginParam) -> Result<()> {
|
||||
self.stop_io().await;
|
||||
let result = self.add_transport_inner(param).await;
|
||||
@@ -141,7 +146,7 @@ impl Context {
|
||||
if let Err(err) = res.as_ref() {
|
||||
// We are using Anyhow's .context() and to show the
|
||||
// inner error, too, we need the {:#}:
|
||||
let error_msg = stock_str::configuration_failed(self, &format!("{err:#}")).await;
|
||||
let error_msg = stock_str::configuration_failed(self, &format!("{err:#}"));
|
||||
progress!(self, 0, Some(error_msg.clone()));
|
||||
bail!(error_msg);
|
||||
} else {
|
||||
@@ -184,14 +189,22 @@ impl Context {
|
||||
/// Returns the list of all email accounts that are used as a transport in the current profile.
|
||||
/// Use [Self::add_or_update_transport()] to add or change a transport
|
||||
/// and [Self::delete_transport()] to delete a transport.
|
||||
pub async fn list_transports(&self) -> Result<Vec<EnteredLoginParam>> {
|
||||
pub async fn list_transports(&self) -> Result<Vec<TransportListEntry>> {
|
||||
let transports = self
|
||||
.sql
|
||||
.query_map_vec("SELECT entered_param FROM transports", (), |row| {
|
||||
let entered_param: String = row.get(0)?;
|
||||
let transport: EnteredLoginParam = serde_json::from_str(&entered_param)?;
|
||||
Ok(transport)
|
||||
})
|
||||
.query_map_vec(
|
||||
"SELECT entered_param, is_published FROM transports",
|
||||
(),
|
||||
|row| {
|
||||
let param: String = row.get(0)?;
|
||||
let param: EnteredLoginParam = serde_json::from_str(¶m)?;
|
||||
let is_published: bool = row.get(1)?;
|
||||
Ok(TransportListEntry {
|
||||
param,
|
||||
is_unpublished: !is_published,
|
||||
})
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(transports)
|
||||
@@ -206,7 +219,8 @@ impl Context {
|
||||
/// (i.e. [EnteredLoginParam::addr]).
|
||||
pub async fn delete_transport(&self, addr: &str) -> Result<()> {
|
||||
let now = time();
|
||||
self.sql
|
||||
let removed_transport_id = self
|
||||
.sql
|
||||
.transaction(|transaction| {
|
||||
let primary_addr = transaction.query_row(
|
||||
"SELECT value FROM config WHERE keyname='configured_addr'",
|
||||
@@ -247,11 +261,50 @@ impl Context {
|
||||
(addr, remove_timestamp),
|
||||
)?;
|
||||
|
||||
Ok(transport_id)
|
||||
})
|
||||
.await?;
|
||||
send_sync_transports(self).await?;
|
||||
self.quota.write().await.remove(&removed_transport_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change whether the transport is unpublished.
|
||||
///
|
||||
/// Unpublished transports are not advertised to contacts,
|
||||
/// and self-sent messages are not sent there,
|
||||
/// so that we don't cause extra messages to the corresponding inbox,
|
||||
/// but can still receive messages from contacts who don't know our new transport addresses yet.
|
||||
///
|
||||
/// The default is false, but when the user updates from a version that didn't have this flag,
|
||||
/// existing secondary transports are set to unpublished,
|
||||
/// so that an existing transport address doesn't suddenly get spammed with a lot of messages.
|
||||
pub async fn set_transport_unpublished(&self, addr: &str, unpublished: bool) -> Result<()> {
|
||||
self.sql
|
||||
.transaction(|trans| {
|
||||
let primary_addr: String = trans
|
||||
.query_row(
|
||||
"SELECT value FROM config WHERE keyname='configured_addr'",
|
||||
(),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.context("Select primary address")?;
|
||||
if primary_addr == addr && unpublished {
|
||||
bail!("Can't set primary relay as unpublished");
|
||||
}
|
||||
// We need to update the timestamp so that the key's timestamp changes
|
||||
// and is recognized as newer by our peers
|
||||
trans
|
||||
.execute(
|
||||
"UPDATE transports SET is_published=?, add_timestamp=? WHERE addr=? AND is_published!=?1",
|
||||
(!unpublished, time(), addr),
|
||||
)
|
||||
.context("Update transports")?;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
send_sync_transports(self).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -268,18 +321,48 @@ impl Context {
|
||||
)
|
||||
.await?
|
||||
{
|
||||
if self.get_config(Config::MvboxMove).await?.as_deref() != Some("0") {
|
||||
bail!("Cannot use multi-transport with mvbox_move enabled.");
|
||||
}
|
||||
// Should be checked before `MvboxMove` because the latter makes no sense in presense of
|
||||
// `OnlyFetchMvbox` and even grayed out in the UIs in this case.
|
||||
if self.get_config(Config::OnlyFetchMvbox).await?.as_deref() != Some("0") {
|
||||
bail!("Cannot use multi-transport with only_fetch_mvbox enabled.");
|
||||
bail!(
|
||||
"To use additional relays, disable the legacy option \"Settings / Advanced / Only Fetch from DeltaChat Folder\"."
|
||||
);
|
||||
}
|
||||
if self.get_config(Config::ShowEmails).await?.as_deref() != Some("2") {
|
||||
bail!("Cannot use multi-transport with disabled fetching of classic emails.");
|
||||
if self.get_config(Config::MvboxMove).await?.as_deref() != Some("0") {
|
||||
bail!(
|
||||
"To use additional relays, disable the legacy option \"Settings / Advanced / Move automatically to DeltaChat Folder\"."
|
||||
);
|
||||
}
|
||||
|
||||
if 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 = configure(self, param).await?;
|
||||
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).await?;
|
||||
@@ -513,9 +596,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());
|
||||
@@ -554,11 +634,11 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
|
||||
let (_s, r) = async_channel::bounded(1);
|
||||
let mut imap = Imap::new(ctx, transport_id, configured_param.clone(), r).await?;
|
||||
let configuring = true;
|
||||
if let Err(err) = imap.connect(ctx, configuring).await {
|
||||
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:#}")));
|
||||
}
|
||||
};
|
||||
|
||||
progress!(ctx, 850);
|
||||
@@ -573,7 +653,17 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
|
||||
ctx.sql.set_raw_config("mvbox_move", Some("0")).await?;
|
||||
ctx.sql.set_raw_config("only_fetch_mvbox", None).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?;
|
||||
}
|
||||
}
|
||||
|
||||
drop(imap_session);
|
||||
drop(imap);
|
||||
|
||||
progress!(ctx, 910);
|
||||
@@ -590,12 +680,12 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
|
||||
|
||||
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);
|
||||
@@ -688,7 +778,7 @@ async fn get_autoconfig(
|
||||
None
|
||||
}
|
||||
|
||||
async fn nicer_configuration_error(context: &Context, e: String) -> String {
|
||||
fn nicer_configuration_error(context: &Context, e: String) -> String {
|
||||
if e.to_lowercase().contains("could not resolve")
|
||||
|| e.to_lowercase().contains("connection attempts")
|
||||
|| e.to_lowercase()
|
||||
@@ -697,7 +787,7 @@ async fn nicer_configuration_error(context: &Context, e: String) -> String {
|
||||
|| e.to_lowercase()
|
||||
.contains("failed to lookup address information")
|
||||
{
|
||||
return stock_str::error_no_network(context).await;
|
||||
return stock_str::error_no_network(context);
|
||||
}
|
||||
|
||||
e
|
||||
|
||||
@@ -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'.');
|
||||
@@ -237,19 +234,6 @@ pub(crate) const TIMESTAMP_SENT_TOLERANCE: i64 = 60;
|
||||
// Newer Delta Chats will remove the prefix as needed.
|
||||
pub(crate) const EDITED_PREFIX: &str = "✏️";
|
||||
|
||||
// Strings needed to render the Autocrypt Setup Message.
|
||||
// Left untranslated as not being supported/recommended workflow and as translations would require deep knowledge.
|
||||
pub(crate) const ASM_SUBJECT: &str = "Autocrypt Setup Message";
|
||||
pub(crate) const ASM_BODY: &str = "This is the Autocrypt Setup Message \
|
||||
used to transfer your end-to-end setup between clients.
|
||||
|
||||
To decrypt and use your setup, \
|
||||
open the message in an Autocrypt-compliant client \
|
||||
and enter the setup code presented on the generating device.
|
||||
|
||||
If you see this message in a chatmail client (Delta Chat, Arcane Chat, Delta Touch ...), \
|
||||
use \"Settings / Add Second Device\" instead.";
|
||||
|
||||
/// Period between `sql::housekeeping()` runs.
|
||||
pub(crate) const HOUSEKEEPING_PERIOD: i64 = 24 * 60 * 60;
|
||||
|
||||
|
||||
243
src/contact.rs
243
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)?;
|
||||
@@ -300,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`,
|
||||
@@ -338,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)
|
||||
@@ -621,7 +688,7 @@ impl Contact {
|
||||
.await?
|
||||
{
|
||||
if contact_id == ContactId::SELF {
|
||||
contact.name = stock_str::self_msg(context).await;
|
||||
contact.name = stock_str::self_msg(context);
|
||||
contact.authname = context
|
||||
.get_config(Config::Displayname)
|
||||
.await?
|
||||
@@ -638,9 +705,9 @@ impl Contact {
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
} else if contact_id == ContactId::DEVICE {
|
||||
contact.name = stock_str::device_messages(context).await;
|
||||
contact.name = stock_str::device_messages(context);
|
||||
contact.addr = ContactId::DEVICE_ADDR.to_string();
|
||||
contact.status = stock_str::device_messages_hint(context).await;
|
||||
contact.status = stock_str::device_messages_hint(context);
|
||||
}
|
||||
Ok(Some(contact))
|
||||
} else {
|
||||
@@ -659,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
|
||||
}
|
||||
@@ -967,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 {
|
||||
@@ -982,11 +1061,7 @@ impl Contact {
|
||||
} else {
|
||||
row_origin
|
||||
},
|
||||
if update_authname {
|
||||
name.to_string()
|
||||
} else {
|
||||
row_authname
|
||||
},
|
||||
&new_authname,
|
||||
row_id,
|
||||
),
|
||||
)?;
|
||||
@@ -998,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 },
|
||||
),
|
||||
)?;
|
||||
|
||||
@@ -1050,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;
|
||||
|
||||
@@ -1112,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)?;
|
||||
@@ -1160,7 +1240,7 @@ impl Contact {
|
||||
|
||||
if self_addr.contains(query)
|
||||
|| self_name.contains(query)
|
||||
|| self_name2.await.contains(query)
|
||||
|| self_name2.contains(query)
|
||||
{
|
||||
add_self = true;
|
||||
}
|
||||
@@ -1178,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)?;
|
||||
@@ -1249,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(())
|
||||
@@ -1259,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)
|
||||
@@ -1309,18 +1392,18 @@ impl Contact {
|
||||
.unwrap_or_default();
|
||||
|
||||
let Some(fingerprint_other) = contact.fingerprint() else {
|
||||
return Ok(stock_str::encr_none(context).await);
|
||||
return Ok(stock_str::encr_none(context));
|
||||
};
|
||||
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)
|
||||
} else {
|
||||
stock_str::encr_none(context).await
|
||||
stock_str::encr_none(context)
|
||||
};
|
||||
|
||||
let finger_prints = stock_str::finger_prints(context).await;
|
||||
let mut ret = format!("{stock_message}.\n{finger_prints}:");
|
||||
let finger_prints = stock_str::finger_prints(context);
|
||||
let mut ret = format!("{stock_message}\n{finger_prints}:");
|
||||
|
||||
let fingerprint_self = load_self_public_key(context)
|
||||
.await?
|
||||
@@ -1329,7 +1412,7 @@ impl Contact {
|
||||
if addr < contact.addr {
|
||||
cat_fingerprint(
|
||||
&mut ret,
|
||||
&stock_str::self_msg(context).await,
|
||||
&stock_str::self_msg(context),
|
||||
&addr,
|
||||
&fingerprint_self,
|
||||
);
|
||||
@@ -1348,12 +1431,22 @@ impl Contact {
|
||||
);
|
||||
cat_fingerprint(
|
||||
&mut ret,
|
||||
&stock_str::self_msg(context).await,
|
||||
&stock_str::self_msg(context),
|
||||
&addr,
|
||||
&fingerprint_self,
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1426,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.
|
||||
@@ -1619,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.
|
||||
@@ -1725,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 {
|
||||
@@ -1882,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,
|
||||
@@ -1973,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}");
|
||||
}
|
||||
@@ -2014,6 +2108,7 @@ impl RecentlySeenLoop {
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn run(context: Context, interrupt: Receiver<RecentlySeenInterrupt>) {
|
||||
type MyHeapElem = (Reverse<i64>, ContactId);
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -265,7 +282,7 @@ async fn test_add_or_lookup() {
|
||||
|
||||
// check SELF
|
||||
let contact = Contact::get_by_id(&t, ContactId::SELF).await.unwrap();
|
||||
assert_eq!(contact.get_name(), stock_str::self_msg(&t).await);
|
||||
assert_eq!(contact.get_name(), stock_str::self_msg(&t));
|
||||
assert_eq!(contact.get_addr(), "alice@example.org");
|
||||
assert!(!contact.is_blocked());
|
||||
}
|
||||
@@ -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?);
|
||||
@@ -1128,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);
|
||||
|
||||
170
src/context.rs
170
src/context.rs
@@ -8,8 +8,9 @@ use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, OnceLock, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result, bail, ensure};
|
||||
use anyhow::{Result, bail, ensure};
|
||||
use async_channel::{self as channel, Receiver, Sender};
|
||||
use pgp::composed::SignedPublicKey;
|
||||
use ratelimit::Ratelimit;
|
||||
use tokio::sync::{Mutex, Notify, RwLock};
|
||||
|
||||
@@ -23,7 +24,6 @@ use crate::imap::{FolderMeaning, Imap, ServerMetadata};
|
||||
use crate::key::self_fingerprint;
|
||||
use crate::log::warn;
|
||||
use crate::logged_debug_assert;
|
||||
use crate::login_param::EnteredLoginParam;
|
||||
use crate::message::{self, MessageState, MsgId};
|
||||
use crate::net::tls::TlsSessionStore;
|
||||
use crate::peer_channels::Iroh;
|
||||
@@ -37,6 +37,8 @@ use crate::tools::{self, duration_to_str, time, time_elapsed};
|
||||
use crate::transport::ConfiguredLoginParam;
|
||||
use crate::{chatlist_events, stats};
|
||||
|
||||
pub use crate::scheduler::connectivity::Connectivity;
|
||||
|
||||
/// Builder for the [`Context`].
|
||||
///
|
||||
/// Many arguments to the [`Context`] are kind of optional and only needed to handle
|
||||
@@ -232,21 +234,30 @@ pub struct InnerContext {
|
||||
/// This is a global mutex-like state for operations which should be modal in the
|
||||
/// clients.
|
||||
running_state: RwLock<RunningState>,
|
||||
/// Mutex to avoid generating the key for the user more than once.
|
||||
pub(crate) generating_key_mutex: Mutex<()>,
|
||||
/// Mutex to enforce only a single running oauth2 is running.
|
||||
pub(crate) oauth2_mutex: Mutex<()>,
|
||||
/// Mutex to prevent a race condition when a "your pw is wrong" warning is sent, resulting in multiple messages being sent.
|
||||
pub(crate) wrong_pw_warning_mutex: Mutex<()>,
|
||||
/// Mutex to prevent running housekeeping from multiple threads at once.
|
||||
pub(crate) housekeeping_mutex: Mutex<()>,
|
||||
|
||||
/// Mutex to prevent multiple IMAP loops from fetching the messages at once.
|
||||
///
|
||||
/// Without this mutex IMAP loops may waste traffic downloading the same message
|
||||
/// from multiple IMAP servers and create multiple copies of the same message
|
||||
/// in the database if the check for duplicates and creating a message
|
||||
/// happens in separate database transactions.
|
||||
pub(crate) fetch_msgs_mutex: Mutex<()>,
|
||||
|
||||
pub(crate) translated_stockstrings: StockStrings,
|
||||
pub(crate) events: Events,
|
||||
|
||||
pub(crate) scheduler: SchedulerState,
|
||||
pub(crate) ratelimit: RwLock<Ratelimit>,
|
||||
|
||||
/// Recently loaded quota information, if any.
|
||||
/// Set to `None` if quota was never tried to load.
|
||||
pub(crate) quota: RwLock<Option<QuotaInfo>>,
|
||||
/// Recently loaded quota information for each trasnport, if any.
|
||||
/// If quota was never tried to load, then the transport doesn't have an entry in the BTreeMap.
|
||||
pub(crate) quota: RwLock<BTreeMap<u32, QuotaInfo>>,
|
||||
|
||||
/// Notify about new messages.
|
||||
///
|
||||
@@ -305,6 +316,13 @@ pub struct InnerContext {
|
||||
/// the standard library's OnceLock is enough, and it's a lot smaller in memory.
|
||||
pub(crate) self_fingerprint: OnceLock<String>,
|
||||
|
||||
/// OpenPGP certificate aka Transferrable Public Key.
|
||||
///
|
||||
/// It is generated on first use from the secret key stored in the database.
|
||||
///
|
||||
/// Mutex is also held while generating the key to avoid generating the key twice.
|
||||
pub(crate) self_public_key: Mutex<Option<SignedPublicKey>>,
|
||||
|
||||
/// `Connectivity` values for mailboxes, unordered. Used to compute the aggregate connectivity,
|
||||
/// see [`Context::get_connectivity()`].
|
||||
pub(crate) connectivities: parking_lot::Mutex<Vec<ConnectivityStore>>,
|
||||
@@ -341,6 +359,7 @@ enum RunningState {
|
||||
/// actual keys and their values which will be present are not
|
||||
/// guaranteed. Calling [Context::get_info] also includes information
|
||||
/// about the context on top of the information here.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub fn get_info() -> BTreeMap<&'static str, String> {
|
||||
let mut res = BTreeMap::new();
|
||||
|
||||
@@ -352,7 +371,7 @@ pub fn get_info() -> BTreeMap<&'static str, String> {
|
||||
#[cfg(not(debug_assertions))]
|
||||
res.insert("debug_assertions", "Off".to_string());
|
||||
|
||||
res.insert("deltachat_core_version", format!("v{}", &*DC_VERSION_STR));
|
||||
res.insert("deltachat_core_version", format!("v{DC_VERSION_STR}"));
|
||||
res.insert("sqlite_version", rusqlite::version().to_string());
|
||||
res.insert("arch", (std::mem::size_of::<usize>() * 8).to_string());
|
||||
res.insert("num_cpus", num_cpus::get().to_string());
|
||||
@@ -473,14 +492,15 @@ impl Context {
|
||||
running_state: RwLock::new(Default::default()),
|
||||
sql: Sql::new(dbfile),
|
||||
smeared_timestamp: SmearedTimestamp::new(),
|
||||
generating_key_mutex: Mutex::new(()),
|
||||
oauth2_mutex: Mutex::new(()),
|
||||
wrong_pw_warning_mutex: Mutex::new(()),
|
||||
housekeeping_mutex: Mutex::new(()),
|
||||
fetch_msgs_mutex: Mutex::new(()),
|
||||
translated_stockstrings: stockstrings,
|
||||
events,
|
||||
scheduler: SchedulerState::new(),
|
||||
ratelimit: RwLock::new(Ratelimit::new(Duration::new(3, 0), 3.0)), // Allow at least 1 message every second + a burst of 3.
|
||||
quota: RwLock::new(None),
|
||||
quota: RwLock::new(BTreeMap::new()),
|
||||
new_msgs_notify,
|
||||
server_id: RwLock::new(None),
|
||||
metadata: RwLock::new(None),
|
||||
@@ -493,6 +513,7 @@ impl Context {
|
||||
tls_session_store: TlsSessionStore::new(),
|
||||
iroh: Arc::new(RwLock::new(None)),
|
||||
self_fingerprint: OnceLock::new(),
|
||||
self_public_key: Mutex::new(None),
|
||||
connectivities: parking_lot::Mutex::new(Vec::new()),
|
||||
pre_encrypt_mime_hook: None.into(),
|
||||
};
|
||||
@@ -615,8 +636,13 @@ impl Context {
|
||||
}
|
||||
|
||||
// Update quota (to send warning if full) - but only check it once in a while.
|
||||
// note: For now this only checks quota of primary transport,
|
||||
// because background check only checks primary transport at the moment
|
||||
if self
|
||||
.quota_needs_update(DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT)
|
||||
.quota_needs_update(
|
||||
session.transport_id(),
|
||||
DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT,
|
||||
)
|
||||
.await
|
||||
&& let Err(err) = self.update_recent_quota(&mut session).await
|
||||
{
|
||||
@@ -816,12 +842,17 @@ impl Context {
|
||||
|
||||
/// Returns information about the context as key-value pairs.
|
||||
pub async fn get_info(&self) -> Result<BTreeMap<&'static str, String>> {
|
||||
let l = EnteredLoginParam::load(self).await?;
|
||||
let l2 = ConfiguredLoginParam::load(self).await?.map_or_else(
|
||||
|| "Not configured".to_string(),
|
||||
|(_transport_id, param)| param.to_string(),
|
||||
);
|
||||
let secondary_addrs = self.get_secondary_self_addrs().await?.join(", ");
|
||||
let all_transports: Vec<String> = ConfiguredLoginParam::load_all(self)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(transport_id, param)| format!("{transport_id}: {param}"))
|
||||
.collect();
|
||||
let all_transports = if all_transports.is_empty() {
|
||||
"Not configured".to_string()
|
||||
} else {
|
||||
all_transports.join(",")
|
||||
};
|
||||
let chats = get_chat_cnt(self).await?;
|
||||
let unblocked_msgs = message::get_unblocked_msg_cnt(self).await;
|
||||
let request_msgs = message::get_request_msg_cnt(self).await;
|
||||
@@ -869,10 +900,6 @@ impl Context {
|
||||
.get_config(Config::ConfiguredMvboxFolder)
|
||||
.await?
|
||||
.unwrap_or_else(|| "<unset>".to_string());
|
||||
let configured_trash_folder = self
|
||||
.get_config(Config::ConfiguredTrashFolder)
|
||||
.await?
|
||||
.unwrap_or_else(|| "<unset>".to_string());
|
||||
|
||||
let mut res = get_info();
|
||||
|
||||
@@ -900,8 +927,7 @@ impl Context {
|
||||
.unwrap_or_else(|| "<unset>".to_string()),
|
||||
);
|
||||
res.insert("proxy_enabled", proxy_enabled.to_string());
|
||||
res.insert("entered_account_settings", l.to_string());
|
||||
res.insert("used_account_settings", l2);
|
||||
res.insert("used_transport_settings", all_transports);
|
||||
|
||||
if let Some(server_id) = &*self.server_id.read().await {
|
||||
res.insert("imap_server_id", format!("{server_id:?}"));
|
||||
@@ -936,16 +962,14 @@ impl Context {
|
||||
}
|
||||
|
||||
res.insert("secondary_addrs", secondary_addrs);
|
||||
res.insert(
|
||||
"fetched_existing_msgs",
|
||||
self.get_config_bool(Config::FetchedExistingMsgs)
|
||||
.await?
|
||||
.to_string(),
|
||||
);
|
||||
res.insert(
|
||||
"show_emails",
|
||||
self.get_config_int(Config::ShowEmails).await?.to_string(),
|
||||
);
|
||||
res.insert(
|
||||
"who_can_call_me",
|
||||
self.get_config_int(Config::WhoCanCallMe).await?.to_string(),
|
||||
);
|
||||
res.insert(
|
||||
"download_limit",
|
||||
self.get_config_int(Config::DownloadLimit)
|
||||
@@ -960,7 +984,6 @@ impl Context {
|
||||
);
|
||||
res.insert("configured_inbox_folder", configured_inbox_folder);
|
||||
res.insert("configured_mvbox_folder", configured_mvbox_folder);
|
||||
res.insert("configured_trash_folder", configured_trash_folder);
|
||||
res.insert("mdns_enabled", mdns_enabled.to_string());
|
||||
res.insert("bcc_self", bcc_self.to_string());
|
||||
res.insert("sync_msgs", sync_msgs.to_string());
|
||||
@@ -984,12 +1007,6 @@ impl Context {
|
||||
.await?
|
||||
.to_string(),
|
||||
);
|
||||
res.insert(
|
||||
"delete_to_trash",
|
||||
self.get_config(Config::DeleteToTrash)
|
||||
.await?
|
||||
.unwrap_or_else(|| "<unset>".to_string()),
|
||||
);
|
||||
res.insert(
|
||||
"last_housekeeping",
|
||||
self.get_config_int(Config::LastHousekeeping)
|
||||
@@ -1002,12 +1019,6 @@ impl Context {
|
||||
.await?
|
||||
.to_string(),
|
||||
);
|
||||
res.insert(
|
||||
"scan_all_folders_debounce_secs",
|
||||
self.get_config_int(Config::ScanAllFoldersDebounceSecs)
|
||||
.await?
|
||||
.to_string(),
|
||||
);
|
||||
res.insert(
|
||||
"quota_exceeding",
|
||||
self.get_config_int(Config::QuotaExceeding)
|
||||
@@ -1080,13 +1091,6 @@ impl Context {
|
||||
.await?
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
res.insert(
|
||||
"fail_on_receiving_full_msg",
|
||||
self.sql
|
||||
.get_raw_config("fail_on_receiving_full_msg")
|
||||
.await?
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
res.insert(
|
||||
"std_header_protection_composing",
|
||||
self.sql
|
||||
@@ -1094,6 +1098,10 @@ impl Context {
|
||||
.await?
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
res.insert(
|
||||
"team_profile",
|
||||
self.get_config_bool(Config::TeamProfile).await?.to_string(),
|
||||
);
|
||||
|
||||
let elapsed = time_elapsed(&self.creation_time);
|
||||
res.insert("uptime", duration_to_str(elapsed));
|
||||
@@ -1111,21 +1119,19 @@ impl Context {
|
||||
let list = self
|
||||
.sql
|
||||
.query_map_vec(
|
||||
concat!(
|
||||
"SELECT m.id",
|
||||
" FROM msgs m",
|
||||
" LEFT JOIN contacts ct",
|
||||
" ON m.from_id=ct.id",
|
||||
" LEFT JOIN chats c",
|
||||
" ON m.chat_id=c.id",
|
||||
" WHERE m.state=?",
|
||||
" AND m.hidden=0",
|
||||
" AND m.chat_id>9",
|
||||
" AND ct.blocked=0",
|
||||
" AND c.blocked=0",
|
||||
" AND NOT(c.muted_until=-1 OR c.muted_until>?)",
|
||||
" ORDER BY m.timestamp DESC,m.id DESC;"
|
||||
),
|
||||
"SELECT m.id
|
||||
FROM msgs m
|
||||
LEFT JOIN contacts ct
|
||||
ON m.from_id=ct.id
|
||||
LEFT JOIN chats c
|
||||
ON m.chat_id=c.id
|
||||
WHERE m.state=?
|
||||
AND m.hidden=0
|
||||
AND m.chat_id>9
|
||||
AND ct.blocked=0
|
||||
AND c.blocked=0
|
||||
AND NOT(c.muted_until=-1 OR c.muted_until>?)
|
||||
ORDER BY m.timestamp DESC,m.id DESC",
|
||||
(MessageState::InFresh, time()),
|
||||
|row| {
|
||||
let msg_id: MsgId = row.get(0)?;
|
||||
@@ -1277,45 +1283,12 @@ impl Context {
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Returns true if given folder name is the name of the inbox.
|
||||
pub async fn is_inbox(&self, folder_name: &str) -> Result<bool> {
|
||||
let inbox = self.get_config(Config::ConfiguredInboxFolder).await?;
|
||||
Ok(inbox.as_deref() == Some(folder_name))
|
||||
}
|
||||
|
||||
/// Returns true if given folder name is the name of the "DeltaChat" folder.
|
||||
pub async fn is_mvbox(&self, folder_name: &str) -> Result<bool> {
|
||||
let mvbox = self.get_config(Config::ConfiguredMvboxFolder).await?;
|
||||
Ok(mvbox.as_deref() == Some(folder_name))
|
||||
}
|
||||
|
||||
/// Returns true if given folder name is the name of the trash folder.
|
||||
pub async fn is_trash(&self, folder_name: &str) -> Result<bool> {
|
||||
let trash = self.get_config(Config::ConfiguredTrashFolder).await?;
|
||||
Ok(trash.as_deref() == Some(folder_name))
|
||||
}
|
||||
|
||||
pub(crate) async fn should_delete_to_trash(&self) -> Result<bool> {
|
||||
if let Some(v) = self.get_config_bool_opt(Config::DeleteToTrash).await? {
|
||||
return Ok(v);
|
||||
}
|
||||
if let Some(provider) = self.get_configured_provider().await? {
|
||||
return Ok(provider.opt.delete_to_trash);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Returns `target` for deleted messages as per `imap` table. Empty string means "delete w/o
|
||||
/// moving to trash".
|
||||
pub(crate) async fn get_delete_msgs_target(&self) -> Result<String> {
|
||||
if !self.should_delete_to_trash().await? {
|
||||
return Ok("".into());
|
||||
}
|
||||
self.get_config(Config::ConfiguredTrashFolder)
|
||||
.await?
|
||||
.context("No configured trash folder")
|
||||
}
|
||||
|
||||
pub(crate) fn derive_blobdir(dbfile: &Path) -> PathBuf {
|
||||
let mut blob_fname = OsString::new();
|
||||
blob_fname.push(dbfile.file_name().unwrap_or_default());
|
||||
@@ -1331,10 +1304,5 @@ impl Context {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns core version as a string.
|
||||
pub fn get_version_str() -> &'static str {
|
||||
&DC_VERSION_STR
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod context_tests;
|
||||
|
||||
@@ -297,6 +297,7 @@ async fn test_get_info_completeness() {
|
||||
"encrypted_device_token",
|
||||
"stats_last_update",
|
||||
"stats_last_old_contact_id",
|
||||
"simulate_receive_imf_error", // only used in tests
|
||||
];
|
||||
let t = TestContext::new().await;
|
||||
let info = t.get_info().await.unwrap();
|
||||
|
||||
260
src/decrypt.rs
260
src/decrypt.rs
@@ -1,34 +1,256 @@
|
||||
//! End-to-end decryption support.
|
||||
//! Helper functions for decryption.
|
||||
//! The actual decryption is done in the [`crate::pgp`] module.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::io::Cursor;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use mailparse::ParsedMail;
|
||||
use pgp::composed::Esk;
|
||||
use pgp::composed::Message;
|
||||
use pgp::composed::PlainSessionKey;
|
||||
use pgp::composed::SignedSecretKey;
|
||||
use pgp::composed::decrypt_session_key_with_password;
|
||||
use pgp::packet::SymKeyEncryptedSessionKey;
|
||||
use pgp::types::Password;
|
||||
use pgp::types::StringToKey;
|
||||
|
||||
use crate::key::{Fingerprint, SignedPublicKey, SignedSecretKey};
|
||||
use crate::pgp;
|
||||
use crate::chat::ChatId;
|
||||
use crate::constants::Chattype;
|
||||
use crate::contact::ContactId;
|
||||
use crate::context::Context;
|
||||
use crate::key::self_fingerprint;
|
||||
use crate::key::{Fingerprint, SignedPublicKey, load_self_secret_keyring};
|
||||
use crate::token::Namespace;
|
||||
|
||||
/// Tries to decrypt a message, but only if it is structured as an Autocrypt message.
|
||||
/// Tries to decrypt the message,
|
||||
/// returning a tuple of `(decrypted message, fingerprint)`.
|
||||
///
|
||||
/// If successful and the message was encrypted,
|
||||
/// returns the decrypted and decompressed message.
|
||||
pub fn try_decrypt<'a>(
|
||||
/// If the message wasn't encrypted, returns `Ok(None)`.
|
||||
///
|
||||
/// If the message was asymmetrically encrypted, returns `Ok((decrypted message, None))`.
|
||||
///
|
||||
/// If the message was symmetrically encrypted, returns `Ok((decrypted message, Some(fingerprint)))`,
|
||||
/// where `fingerprint` denotes which contact is allowed to send encrypted with this symmetric secret.
|
||||
/// If the message is not signed by `fingerprint`, it must be dropped.
|
||||
///
|
||||
/// Otherwise, Eve could send a message to Alice
|
||||
/// encrypted with the symmetric secret of someone else's broadcast channel.
|
||||
/// If Alice sends an answer (or read receipt),
|
||||
/// then Eve would know that Alice is in the broadcast channel.
|
||||
pub(crate) async fn decrypt(
|
||||
context: &Context,
|
||||
mail: &mailparse::ParsedMail<'_>,
|
||||
) -> Result<Option<(Message<'static>, Option<String>)>> {
|
||||
// `pgp::composed::Message` is huge (>4kb), so, make sure that it is in a Box when held over an await point
|
||||
let Some(msg) = get_encrypted_pgp_message_boxed(mail)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let expected_sender_fingerprint: Option<String>;
|
||||
|
||||
let plain = if let Message::Encrypted { esk, .. } = &*msg
|
||||
// We only allow one ESK for symmetrically encrypted messages
|
||||
// to avoid dealing with messages that are encrypted to multiple symmetric keys
|
||||
// or a mix of symmetric and asymmetric keys:
|
||||
&& let [Esk::SymKeyEncryptedSessionKey(esk)] = &esk[..]
|
||||
{
|
||||
check_symmetric_encryption(esk)?;
|
||||
let (psk, fingerprint) = decrypt_session_key_symmetrically(context, esk)
|
||||
.await
|
||||
.context("decrypt_session_key_symmetrically")?;
|
||||
expected_sender_fingerprint = fingerprint;
|
||||
|
||||
tokio::task::spawn_blocking(move || -> Result<Message<'_>> {
|
||||
let plain = msg
|
||||
.decrypt_with_session_key(psk)
|
||||
.context("decrypt_with_session_key")?;
|
||||
|
||||
let plain: Message<'static> = plain.decompress()?;
|
||||
Ok(plain)
|
||||
})
|
||||
.await??
|
||||
} else {
|
||||
// Message is asymmetrically encrypted
|
||||
let secret_keys: Vec<SignedSecretKey> = load_self_secret_keyring(context).await?;
|
||||
expected_sender_fingerprint = None;
|
||||
|
||||
tokio::task::spawn_blocking(move || -> Result<Message<'_>> {
|
||||
let empty_pw = Password::empty();
|
||||
let secret_keys: Vec<&SignedSecretKey> = secret_keys.iter().collect();
|
||||
let plain = msg
|
||||
.decrypt_with_keys(vec![&empty_pw], secret_keys)
|
||||
.context("decrypt_with_keys")?;
|
||||
|
||||
let plain: Message<'static> = plain.decompress()?;
|
||||
Ok(plain)
|
||||
})
|
||||
.await??
|
||||
};
|
||||
|
||||
Ok(Some((plain, expected_sender_fingerprint)))
|
||||
}
|
||||
|
||||
async fn decrypt_session_key_symmetrically(
|
||||
context: &Context,
|
||||
esk: &SymKeyEncryptedSessionKey,
|
||||
) -> Result<(PlainSessionKey, Option<String>)> {
|
||||
let self_fp = self_fingerprint(context).await?;
|
||||
let query_only = true;
|
||||
context
|
||||
.sql
|
||||
.call(query_only, |conn| {
|
||||
// First, try decrypting using AUTH tokens from scanned QR codes, stored in the bobstate,
|
||||
// because usually there will only be 1 or 2 of it, so, it should be fast
|
||||
let res: Option<(PlainSessionKey, String)> = try_decrypt_with_bobstate(esk, conn)?;
|
||||
if let Some((plain_session_key, fingerprint)) = res {
|
||||
return Ok((plain_session_key, Some(fingerprint)));
|
||||
}
|
||||
|
||||
// Then, try decrypting using broadcast secrets
|
||||
let res: Option<(PlainSessionKey, Option<String>)> =
|
||||
try_decrypt_with_broadcast_secret(esk, conn)?;
|
||||
if let Some((plain_session_key, fingerprint)) = res {
|
||||
return Ok((plain_session_key, fingerprint));
|
||||
}
|
||||
|
||||
// Finally, try decrypting using own AUTH tokens
|
||||
// There can be a lot of AUTH tokens,
|
||||
// because a new one is generated every time a QR code is shown
|
||||
let res: Option<PlainSessionKey> = try_decrypt_with_auth_token(esk, conn, self_fp)?;
|
||||
if let Some(plain_session_key) = res {
|
||||
return Ok((plain_session_key, None));
|
||||
}
|
||||
|
||||
bail!("Could not find symmetric secret for session key")
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn try_decrypt_with_bobstate(
|
||||
esk: &SymKeyEncryptedSessionKey,
|
||||
conn: &mut rusqlite::Connection,
|
||||
) -> Result<Option<(PlainSessionKey, String)>> {
|
||||
let mut stmt = conn.prepare("SELECT invite FROM bobstate")?;
|
||||
let mut rows = stmt.query(())?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let invite: crate::securejoin::QrInvite = row.get(0)?;
|
||||
let authcode = invite.authcode().to_string();
|
||||
let alice_fp = invite.fingerprint().hex();
|
||||
let shared_secret = format!("securejoin/{alice_fp}/{authcode}");
|
||||
if let Ok(psk) = decrypt_session_key_with_password(esk, &Password::from(shared_secret)) {
|
||||
let fingerprint = invite.fingerprint().hex();
|
||||
return Ok(Some((psk, fingerprint)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn try_decrypt_with_broadcast_secret(
|
||||
esk: &SymKeyEncryptedSessionKey,
|
||||
conn: &mut rusqlite::Connection,
|
||||
) -> Result<Option<(PlainSessionKey, Option<String>)>> {
|
||||
let Some((psk, chat_id)) = try_decrypt_with_broadcast_secret_inner(esk, conn)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let chat_type: Chattype =
|
||||
conn.query_one("SELECT type FROM chats WHERE id=?", (chat_id,), |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
let fp: Option<String> = if chat_type == Chattype::OutBroadcast {
|
||||
// An attacker who knows the secret will also know who owns it,
|
||||
// and it's easiest code-wise to just return None here.
|
||||
// But we could alternatively return the self fingerprint here
|
||||
None
|
||||
} else if chat_type == Chattype::InBroadcast {
|
||||
let contact_id: ContactId = conn
|
||||
.query_one(
|
||||
"SELECT contact_id FROM chats_contacts WHERE chat_id=? AND contact_id>9",
|
||||
(chat_id,),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.context("Find InBroadcast owner")?;
|
||||
let fp = conn
|
||||
.query_one(
|
||||
"SELECT fingerprint FROM contacts WHERE id=?",
|
||||
(contact_id,),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.context("Find owner fingerprint")?;
|
||||
Some(fp)
|
||||
} else {
|
||||
bail!("Chat {chat_id} is not a broadcast but {chat_type}")
|
||||
};
|
||||
Ok(Some((psk, fp)))
|
||||
}
|
||||
|
||||
fn try_decrypt_with_broadcast_secret_inner(
|
||||
esk: &SymKeyEncryptedSessionKey,
|
||||
conn: &mut rusqlite::Connection,
|
||||
) -> Result<Option<(PlainSessionKey, ChatId)>> {
|
||||
let mut stmt = conn.prepare("SELECT secret, chat_id FROM broadcast_secrets")?;
|
||||
let mut rows = stmt.query(())?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let secret: String = row.get(0)?;
|
||||
if let Ok(psk) = decrypt_session_key_with_password(esk, &Password::from(secret)) {
|
||||
let chat_id: ChatId = row.get(1)?;
|
||||
return Ok(Some((psk, chat_id)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn try_decrypt_with_auth_token(
|
||||
esk: &SymKeyEncryptedSessionKey,
|
||||
conn: &mut rusqlite::Connection,
|
||||
self_fingerprint: &str,
|
||||
) -> Result<Option<PlainSessionKey>> {
|
||||
// ORDER BY id DESC to query the most-recently saved tokens are returned first.
|
||||
// This improves performance when Bob scans a QR code that was just created.
|
||||
let mut stmt = conn.prepare("SELECT token FROM tokens WHERE namespc=? ORDER BY id DESC")?;
|
||||
let mut rows = stmt.query((Namespace::Auth,))?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let token: String = row.get(0)?;
|
||||
let shared_secret = format!("securejoin/{self_fingerprint}/{token}");
|
||||
if let Ok(psk) = decrypt_session_key_with_password(esk, &Password::from(shared_secret)) {
|
||||
return Ok(Some(psk));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Returns Ok(()) if we want to try symmetrically decrypting the message,
|
||||
/// and Err with a reason if symmetric decryption should not be tried.
|
||||
///
|
||||
/// A DoS attacker could send a message with a lot of encrypted session keys,
|
||||
/// all of which use a very hard-to-compute string2key algorithm.
|
||||
/// We would then try to decrypt all of the encrypted session keys
|
||||
/// with all of the known shared secrets.
|
||||
/// In order to prevent this, we do not try to symmetrically decrypt messages
|
||||
/// that use a string2key algorithm other than 'Salted'.
|
||||
pub(crate) fn check_symmetric_encryption(esk: &SymKeyEncryptedSessionKey) -> Result<()> {
|
||||
match esk.s2k() {
|
||||
Some(StringToKey::Salted { .. }) => Ok(()),
|
||||
_ => bail!("unsupported string2key algorithm"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns a [`ParsedMail`] into [`pgp::composed::Message`].
|
||||
/// [`pgp::composed::Message`] is huge (over 4kb),
|
||||
/// so, it is put on the heap using [`Box`].
|
||||
pub fn get_encrypted_pgp_message_boxed<'a>(
|
||||
mail: &'a ParsedMail<'a>,
|
||||
private_keyring: &'a [SignedSecretKey],
|
||||
shared_secrets: &[String],
|
||||
) -> Result<Option<::pgp::composed::Message<'static>>> {
|
||||
) -> Result<Option<Box<Message<'static>>>> {
|
||||
let Some(encrypted_data_part) = get_encrypted_mime(mail) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let data = encrypted_data_part.get_body_raw()?;
|
||||
let msg = pgp::decrypt(data, private_keyring, shared_secrets)?;
|
||||
|
||||
Ok(Some(msg))
|
||||
let cursor = Cursor::new(data);
|
||||
let (msg, _headers) = Message::from_armor(cursor)?;
|
||||
Ok(Some(Box::new(msg)))
|
||||
}
|
||||
|
||||
/// Returns a reference to the encrypted payload of a message.
|
||||
pub(crate) fn get_encrypted_mime<'a, 'b>(mail: &'a ParsedMail<'b>) -> Option<&'a ParsedMail<'b>> {
|
||||
pub fn get_encrypted_mime<'a, 'b>(mail: &'a ParsedMail<'b>) -> Option<&'a ParsedMail<'b>> {
|
||||
get_autocrypt_mime(mail)
|
||||
.or_else(|| get_mixed_up_mime(mail))
|
||||
.or_else(|| get_attachment_mime(mail))
|
||||
@@ -131,8 +353,10 @@ pub(crate) fn validate_detached_signature<'a, 'b>(
|
||||
// First part is the content, second part is the signature.
|
||||
let content = first_part.raw_bytes;
|
||||
let ret_valid_signatures = match second_part.get_body_raw() {
|
||||
Ok(signature) => pgp::pk_validate(content, &signature, public_keyring_for_validate)
|
||||
.unwrap_or_default(),
|
||||
Ok(signature) => {
|
||||
crate::pgp::pk_validate(content, &signature, public_keyring_for_validate)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
Err(_) => Default::default(),
|
||||
};
|
||||
Some((first_part, ret_valid_signatures))
|
||||
|
||||
@@ -235,6 +235,7 @@ fn str_cb(event_str: &str, dehtml: &mut Dehtml) {
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn dehtml_endtag_cb(event: &BytesEnd, dehtml: &mut Dehtml) {
|
||||
let tag = String::from_utf8_lossy(event.name().as_ref())
|
||||
.trim()
|
||||
@@ -265,21 +266,18 @@ fn dehtml_endtag_cb(event: &BytesEnd, dehtml: &mut Dehtml) {
|
||||
}
|
||||
}
|
||||
}
|
||||
"b" | "strong" => {
|
||||
if dehtml.get_add_text() != AddText::No {
|
||||
*dehtml.get_buf() += "*";
|
||||
}
|
||||
"b" | "strong" if dehtml.get_add_text() != AddText::No => {
|
||||
*dehtml.get_buf() += "*";
|
||||
}
|
||||
"i" | "em" => {
|
||||
if dehtml.get_add_text() != AddText::No {
|
||||
*dehtml.get_buf() += "_";
|
||||
}
|
||||
"i" | "em" if dehtml.get_add_text() != AddText::No => {
|
||||
*dehtml.get_buf() += "_";
|
||||
}
|
||||
"blockquote" => pop_tag(&mut dehtml.blockquotes_since_blockquote),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn dehtml_starttag_cb<B: std::io::BufRead>(
|
||||
event: &BytesStart,
|
||||
dehtml: &mut Dehtml,
|
||||
@@ -339,15 +337,11 @@ fn dehtml_starttag_cb<B: std::io::BufRead>(
|
||||
}
|
||||
}
|
||||
}
|
||||
"b" | "strong" => {
|
||||
if dehtml.get_add_text() != AddText::No {
|
||||
*dehtml.get_buf() += "*";
|
||||
}
|
||||
"b" | "strong" if dehtml.get_add_text() != AddText::No => {
|
||||
*dehtml.get_buf() += "*";
|
||||
}
|
||||
"i" | "em" => {
|
||||
if dehtml.get_add_text() != AddText::No {
|
||||
*dehtml.get_buf() += "_";
|
||||
}
|
||||
"i" | "em" if dehtml.get_add_text() != AddText::No => {
|
||||
*dehtml.get_buf() += "_";
|
||||
}
|
||||
"blockquote" => dehtml.blockquotes_since_blockquote += 1,
|
||||
_ => {}
|
||||
@@ -356,6 +350,7 @@ fn dehtml_starttag_cb<B: std::io::BufRead>(
|
||||
|
||||
/// In order to know when a specific tag is closed, we need to count the opening and closing tags.
|
||||
/// The `counts`s are stored in the `Dehtml` struct.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn pop_tag(count: &mut u32) {
|
||||
if *count > 0 {
|
||||
*count -= 1;
|
||||
@@ -364,6 +359,7 @@ fn pop_tag(count: &mut u32) {
|
||||
|
||||
/// In order to know when a specific tag is closed, we need to count the opening and closing tags.
|
||||
/// The `counts`s are stored in the `Dehtml` struct.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn maybe_push_tag(
|
||||
event: &BytesStart,
|
||||
reader: &Reader<impl BufRead>,
|
||||
|
||||
536
src/download.rs
536
src/download.rs
@@ -1,27 +1,19 @@
|
||||
//! # Download large messages manually.
|
||||
|
||||
use std::cmp::max;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use anyhow::{Result, anyhow, bail, ensure};
|
||||
use deltachat_derive::{FromSql, ToSql};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::context::Context;
|
||||
use crate::imap::session::Session;
|
||||
use crate::message::{Message, MsgId, Viewtype};
|
||||
use crate::mimeparser::{MimeMessage, Part};
|
||||
use crate::tools::time;
|
||||
use crate::{EventType, chatlist_events, stock_str};
|
||||
use crate::log::warn;
|
||||
use crate::message::{self, Message, MsgId, rfc724_mid_exists};
|
||||
use crate::{EventType, chatlist_events};
|
||||
|
||||
/// Download limits should not be used below `MIN_DOWNLOAD_LIMIT`.
|
||||
///
|
||||
/// For better UX, some messages as add-member, non-delivery-reports (NDN) or read-receipts (MDN)
|
||||
/// should always be downloaded completely to handle them correctly,
|
||||
/// also in larger groups and if group and contact avatar are attached.
|
||||
/// Most of these cases are caught by `MIN_DOWNLOAD_LIMIT`.
|
||||
pub(crate) const MIN_DOWNLOAD_LIMIT: u32 = 163840;
|
||||
pub(crate) mod post_msg_metadata;
|
||||
pub(crate) use post_msg_metadata::PostMsgMetadata;
|
||||
|
||||
/// If a message is downloaded only partially
|
||||
/// and `delete_server_after` is set to small timeouts (eg. "at once"),
|
||||
@@ -29,6 +21,16 @@ pub(crate) const MIN_DOWNLOAD_LIMIT: u32 = 163840;
|
||||
/// `MIN_DELETE_SERVER_AFTER` increases the timeout in this case.
|
||||
pub(crate) const MIN_DELETE_SERVER_AFTER: i64 = 48 * 60 * 60;
|
||||
|
||||
/// From this point onward outgoing messages are considered large
|
||||
/// and get a Pre-Message, which announces the Post-Message.
|
||||
/// This is only about sending so we can modify it any time.
|
||||
/// Current value is a bit less than the minimum auto-download setting from the UIs (which is 160
|
||||
/// KiB).
|
||||
pub(crate) const PRE_MSG_ATTACHMENT_SIZE_THRESHOLD: u64 = 140_000;
|
||||
|
||||
/// Max size for pre messages. A warning is emitted when this is exceeded.
|
||||
pub(crate) const PRE_MSG_SIZE_WARNING_THRESHOLD: usize = 150_000;
|
||||
|
||||
/// Download state of the message.
|
||||
#[derive(
|
||||
Debug,
|
||||
@@ -64,20 +66,8 @@ pub enum DownloadState {
|
||||
InProgress = 1000,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
// Returns validated download limit or `None` for "no limit".
|
||||
pub(crate) async fn download_limit(&self) -> Result<Option<u32>> {
|
||||
let download_limit = self.get_config_int(Config::DownloadLimit).await?;
|
||||
if download_limit <= 0 {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(max(MIN_DOWNLOAD_LIMIT, download_limit as u32)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MsgId {
|
||||
/// Schedules full message download for partially downloaded message.
|
||||
/// Schedules Post-Message download for partially downloaded message.
|
||||
pub async fn download_full(self, context: &Context) -> Result<()> {
|
||||
let msg = Message::load_from_db(context, self).await?;
|
||||
match msg.download_state() {
|
||||
@@ -86,11 +76,22 @@ impl MsgId {
|
||||
}
|
||||
DownloadState::InProgress => return Err(anyhow!("Download already in progress.")),
|
||||
DownloadState::Available | DownloadState::Failure => {
|
||||
if msg.rfc724_mid().is_empty() {
|
||||
return Err(anyhow!("Download not possible, message has no rfc724_mid"));
|
||||
}
|
||||
self.update_download_state(context, DownloadState::InProgress)
|
||||
.await?;
|
||||
info!(
|
||||
context,
|
||||
"Requesting full download of {:?}.",
|
||||
msg.rfc724_mid()
|
||||
);
|
||||
context
|
||||
.sql
|
||||
.execute("INSERT INTO download (msg_id) VALUES (?)", (self,))
|
||||
.execute(
|
||||
"INSERT INTO download (rfc724_mid, msg_id) VALUES (?,?)",
|
||||
(msg.rfc724_mid(), msg.id),
|
||||
)
|
||||
.await?;
|
||||
context.scheduler.interrupt_inbox().await;
|
||||
}
|
||||
@@ -98,7 +99,8 @@ impl MsgId {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Updates the message download state. Returns `Ok` if the message doesn't exist anymore.
|
||||
/// Updates the message download state. Returns `Ok` if the message doesn't exist anymore or has
|
||||
/// the download state up to date.
|
||||
pub(crate) async fn update_download_state(
|
||||
self,
|
||||
context: &Context,
|
||||
@@ -107,7 +109,7 @@ impl MsgId {
|
||||
if context
|
||||
.sql
|
||||
.execute(
|
||||
"UPDATE msgs SET download_state=? WHERE id=?;",
|
||||
"UPDATE msgs SET download_state=? WHERE id=? AND download_state<>?1",
|
||||
(download_state, self),
|
||||
)
|
||||
.await?
|
||||
@@ -134,47 +136,47 @@ impl Message {
|
||||
}
|
||||
}
|
||||
|
||||
/// Actually download a message partially downloaded before.
|
||||
/// Actually downloads a message partially downloaded before if the message is available on the
|
||||
/// session transport, in which case returns `Some`. If the message is available on another
|
||||
/// transport, returns `None`.
|
||||
///
|
||||
/// Most messages are downloaded automatically on fetch instead.
|
||||
pub(crate) async fn download_msg(
|
||||
context: &Context,
|
||||
msg_id: MsgId,
|
||||
rfc724_mid: String,
|
||||
session: &mut Session,
|
||||
) -> Result<()> {
|
||||
let Some(msg) = Message::load_from_db_optional(context, msg_id).await? else {
|
||||
// If partially downloaded message was already deleted
|
||||
// we do not know its Message-ID anymore
|
||||
// so cannot download it.
|
||||
//
|
||||
// Probably the message expired due to `delete_device_after`
|
||||
// setting or was otherwise removed from the device,
|
||||
// so we don't want it to reappear anyway.
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
) -> Result<Option<()>> {
|
||||
let transport_id = session.transport_id();
|
||||
let row = context
|
||||
.sql
|
||||
.query_row_optional(
|
||||
"SELECT uid, folder FROM imap WHERE rfc724_mid=? AND target!=''",
|
||||
(&msg.rfc724_mid,),
|
||||
"SELECT uid, folder, transport_id FROM imap
|
||||
WHERE rfc724_mid=? AND target!=''
|
||||
ORDER BY transport_id=? DESC LIMIT 1",
|
||||
(&rfc724_mid, transport_id),
|
||||
|row| {
|
||||
let server_uid: u32 = row.get(0)?;
|
||||
let server_folder: String = row.get(1)?;
|
||||
Ok((server_uid, server_folder))
|
||||
let msg_transport_id: u32 = row.get(2)?;
|
||||
Ok((server_uid, server_folder, msg_transport_id))
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some((server_uid, server_folder)) = row else {
|
||||
let Some((server_uid, server_folder, msg_transport_id)) = row else {
|
||||
// No IMAP record found, we don't know the UID and folder.
|
||||
return Err(anyhow!("Call download_full() again to try over."));
|
||||
delete_from_available_post_msgs(context, &rfc724_mid).await?;
|
||||
return Err(anyhow!(
|
||||
"IMAP location for {rfc724_mid:?} post-message is unknown"
|
||||
));
|
||||
};
|
||||
|
||||
if msg_transport_id != transport_id {
|
||||
return Ok(None);
|
||||
}
|
||||
session
|
||||
.fetch_single_msg(context, &server_folder, server_uid, msg.rfc724_mid.clone())
|
||||
.fetch_single_msg(context, &server_folder, server_uid, rfc724_mid)
|
||||
.await?;
|
||||
Ok(())
|
||||
Ok(Some(()))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -193,10 +195,7 @@ impl Session {
|
||||
bail!("Attempt to fetch UID 0");
|
||||
}
|
||||
|
||||
let create = false;
|
||||
let folder_exists = self
|
||||
.select_with_uidvalidity(context, folder, create)
|
||||
.await?;
|
||||
let folder_exists = self.select_with_uidvalidity(context, folder).await?;
|
||||
ensure!(folder_exists, "No folder {folder}");
|
||||
|
||||
// we are connected, and the folder is selected
|
||||
@@ -205,7 +204,7 @@ impl Session {
|
||||
let mut uid_message_ids: BTreeMap<u32, String> = BTreeMap::new();
|
||||
uid_message_ids.insert(uid, rfc724_mid);
|
||||
let (sender, receiver) = async_channel::unbounded();
|
||||
self.fetch_many_msgs(context, folder, vec![uid], &uid_message_ids, false, sender)
|
||||
self.fetch_many_msgs(context, folder, vec![uid], &uid_message_ids, sender)
|
||||
.await?;
|
||||
if receiver.recv().await.is_err() {
|
||||
bail!("Failed to fetch UID {uid}");
|
||||
@@ -214,41 +213,142 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
impl MimeMessage {
|
||||
/// Creates a placeholder part and add that to `parts`.
|
||||
///
|
||||
/// To create the placeholder, only the outermost header can be used,
|
||||
/// the mime-structure itself is not available.
|
||||
///
|
||||
/// The placeholder part currently contains a text with size and availability of the message.
|
||||
pub(crate) async fn create_stub_from_partial_download(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
org_bytes: u32,
|
||||
) -> Result<()> {
|
||||
let mut text = format!(
|
||||
"[{}]",
|
||||
stock_str::partial_download_msg_body(context, org_bytes).await
|
||||
);
|
||||
if let Some(delete_server_after) = context.get_config_delete_server_after().await? {
|
||||
let until = stock_str::download_availability(
|
||||
context,
|
||||
time() + max(delete_server_after, MIN_DELETE_SERVER_AFTER),
|
||||
)
|
||||
.await;
|
||||
text += format!(" [{until}]").as_str();
|
||||
};
|
||||
|
||||
info!(context, "Partial download: {}", text);
|
||||
|
||||
self.do_add_single_part(Part {
|
||||
typ: Viewtype::Text,
|
||||
msg: text,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
Ok(())
|
||||
async fn set_state_to_failure(context: &Context, rfc724_mid: &str) -> Result<()> {
|
||||
if let Some(msg_id) = rfc724_mid_exists(context, rfc724_mid).await? {
|
||||
// Update download state to failure
|
||||
// so it can be retried.
|
||||
//
|
||||
// On success update_download_state() is not needed
|
||||
// as receive_imf() already
|
||||
// set the state and emitted the event.
|
||||
msg_id
|
||||
.update_download_state(context, DownloadState::Failure)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn available_post_msgs_contains_rfc724_mid(
|
||||
context: &Context,
|
||||
rfc724_mid: &str,
|
||||
) -> Result<bool> {
|
||||
Ok(context
|
||||
.sql
|
||||
.query_get_value::<String>(
|
||||
"SELECT rfc724_mid FROM available_post_msgs WHERE rfc724_mid=?",
|
||||
(&rfc724_mid,),
|
||||
)
|
||||
.await?
|
||||
.is_some())
|
||||
}
|
||||
|
||||
async fn delete_from_available_post_msgs(context: &Context, rfc724_mid: &str) -> Result<()> {
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"DELETE FROM available_post_msgs WHERE rfc724_mid=?",
|
||||
(&rfc724_mid,),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_from_downloads(context: &Context, rfc724_mid: &str) -> Result<()> {
|
||||
context
|
||||
.sql
|
||||
.execute("DELETE FROM download WHERE rfc724_mid=?", (&rfc724_mid,))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn msg_is_downloaded_for(context: &Context, rfc724_mid: &str) -> Result<bool> {
|
||||
Ok(message::rfc724_mid_exists(context, rfc724_mid)
|
||||
.await?
|
||||
.is_some())
|
||||
}
|
||||
|
||||
pub(crate) async fn download_msgs(context: &Context, session: &mut Session) -> Result<()> {
|
||||
let rfc724_mids = context
|
||||
.sql
|
||||
.query_map_vec("SELECT rfc724_mid FROM download", (), |row| {
|
||||
let rfc724_mid: String = row.get(0)?;
|
||||
Ok(rfc724_mid)
|
||||
})
|
||||
.await?;
|
||||
|
||||
for rfc724_mid in &rfc724_mids {
|
||||
let res = download_msg(context, rfc724_mid.clone(), session).await;
|
||||
if let Ok(Some(())) = res {
|
||||
delete_from_downloads(context, rfc724_mid).await?;
|
||||
delete_from_available_post_msgs(context, rfc724_mid).await?;
|
||||
}
|
||||
if let Err(err) = res {
|
||||
warn!(
|
||||
context,
|
||||
"Failed to download message rfc724_mid={rfc724_mid}: {:#}.", err
|
||||
);
|
||||
if !msg_is_downloaded_for(context, rfc724_mid).await? {
|
||||
// This is probably a classical email that vanished before we could download it
|
||||
warn!(
|
||||
context,
|
||||
"{rfc724_mid} download failed and there is no downloaded pre-message."
|
||||
);
|
||||
delete_from_downloads(context, rfc724_mid).await?;
|
||||
} else if available_post_msgs_contains_rfc724_mid(context, rfc724_mid).await? {
|
||||
warn!(
|
||||
context,
|
||||
"{rfc724_mid} is in available_post_msgs table but we failed to fetch it,
|
||||
so set the message to DownloadState::Failure - probably it was deleted on the server in the meantime"
|
||||
);
|
||||
set_state_to_failure(context, rfc724_mid).await?;
|
||||
delete_from_downloads(context, rfc724_mid).await?;
|
||||
delete_from_available_post_msgs(context, rfc724_mid).await?;
|
||||
} else {
|
||||
// leave the message in DownloadState::InProgress;
|
||||
// it will be downloaded once it arrives.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Downloads known post-messages without pre-messages
|
||||
/// in order to guard against lost pre-messages.
|
||||
pub(crate) async fn download_known_post_messages_without_pre_message(
|
||||
context: &Context,
|
||||
session: &mut Session,
|
||||
) -> Result<()> {
|
||||
let rfc724_mids = context
|
||||
.sql
|
||||
.query_map_vec("SELECT rfc724_mid FROM available_post_msgs", (), |row| {
|
||||
let rfc724_mid: String = row.get(0)?;
|
||||
Ok(rfc724_mid)
|
||||
})
|
||||
.await?;
|
||||
for rfc724_mid in &rfc724_mids {
|
||||
if msg_is_downloaded_for(context, rfc724_mid).await? {
|
||||
delete_from_available_post_msgs(context, rfc724_mid).await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Download the Post-Message unconditionally,
|
||||
// because the Pre-Message got lost.
|
||||
// The message may be in the wrong order,
|
||||
// but at least we have it at all.
|
||||
let res = download_msg(context, rfc724_mid.clone(), session).await;
|
||||
if let Ok(Some(())) = res {
|
||||
delete_from_available_post_msgs(context, rfc724_mid).await?;
|
||||
}
|
||||
if let Err(err) = res {
|
||||
warn!(
|
||||
context,
|
||||
"download_known_post_messages_without_pre_message: Failed to download message rfc724_mid={rfc724_mid}: {:#}.",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -256,11 +356,8 @@ mod tests {
|
||||
use num_traits::FromPrimitive;
|
||||
|
||||
use super::*;
|
||||
use crate::chat::{get_chat_msgs, send_msg};
|
||||
use crate::ephemeral::Timer;
|
||||
use crate::message::delete_msgs;
|
||||
use crate::receive_imf::receive_imf_from_inbox;
|
||||
use crate::test_utils::{E2EE_INFO_MSGS, TestContext, TestContextManager};
|
||||
use crate::chat::send_msg;
|
||||
use crate::test_utils::TestContext;
|
||||
|
||||
#[test]
|
||||
fn test_downloadstate_values() {
|
||||
@@ -278,29 +375,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_download_limit() -> Result<()> {
|
||||
let t = TestContext::new_alice().await;
|
||||
|
||||
assert_eq!(t.download_limit().await?, None);
|
||||
|
||||
t.set_config(Config::DownloadLimit, Some("200000")).await?;
|
||||
assert_eq!(t.download_limit().await?, Some(200000));
|
||||
|
||||
t.set_config(Config::DownloadLimit, Some("20000")).await?;
|
||||
assert_eq!(t.download_limit().await?, Some(MIN_DOWNLOAD_LIMIT));
|
||||
|
||||
t.set_config(Config::DownloadLimit, None).await?;
|
||||
assert_eq!(t.download_limit().await?, None);
|
||||
|
||||
for val in &["0", "-1", "-100", "", "foo"] {
|
||||
t.set_config(Config::DownloadLimit, Some(val)).await?;
|
||||
assert_eq!(t.download_limit().await?, None);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_update_download_state() -> Result<()> {
|
||||
let t = TestContext::new_alice().await;
|
||||
@@ -332,230 +406,4 @@ mod tests {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_partial_receive_imf() -> Result<()> {
|
||||
let t = TestContext::new_alice().await;
|
||||
|
||||
let header = "Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\
|
||||
From: bob@example.com\n\
|
||||
To: alice@example.org\n\
|
||||
Subject: foo\n\
|
||||
Message-ID: <Mr.12345678901@example.com>\n\
|
||||
Chat-Version: 1.0\n\
|
||||
Date: Sun, 22 Mar 2020 22:37:57 +0000\
|
||||
Content-Type: text/plain";
|
||||
|
||||
receive_imf_from_inbox(
|
||||
&t,
|
||||
"Mr.12345678901@example.com",
|
||||
header.as_bytes(),
|
||||
false,
|
||||
Some(100000),
|
||||
)
|
||||
.await?;
|
||||
let msg = t.get_last_msg().await;
|
||||
assert_eq!(msg.download_state(), DownloadState::Available);
|
||||
assert_eq!(msg.get_subject(), "foo");
|
||||
assert!(
|
||||
msg.get_text()
|
||||
.contains(&stock_str::partial_download_msg_body(&t, 100000).await)
|
||||
);
|
||||
|
||||
receive_imf_from_inbox(
|
||||
&t,
|
||||
"Mr.12345678901@example.com",
|
||||
format!("{header}\n\n100k text...").as_bytes(),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let msg = t.get_last_msg().await;
|
||||
assert_eq!(msg.download_state(), DownloadState::Done);
|
||||
assert_eq!(msg.get_subject(), "foo");
|
||||
assert_eq!(msg.get_text(), "100k text...");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_partial_download_and_ephemeral() -> Result<()> {
|
||||
let t = TestContext::new_alice().await;
|
||||
let chat_id = t
|
||||
.create_chat_with_contact("bob", "bob@example.org")
|
||||
.await
|
||||
.id;
|
||||
chat_id
|
||||
.set_ephemeral_timer(&t, Timer::Enabled { duration: 60 })
|
||||
.await?;
|
||||
|
||||
// download message from bob partially, this must not change the ephemeral timer
|
||||
receive_imf_from_inbox(
|
||||
&t,
|
||||
"first@example.org",
|
||||
b"From: Bob <bob@example.org>\n\
|
||||
To: Alice <alice@example.org>\n\
|
||||
Chat-Version: 1.0\n\
|
||||
Subject: subject\n\
|
||||
Message-ID: <first@example.org>\n\
|
||||
Date: Sun, 14 Nov 2021 00:10:00 +0000\
|
||||
Content-Type: text/plain",
|
||||
false,
|
||||
Some(100000),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
chat_id.get_ephemeral_timer(&t).await?,
|
||||
Timer::Enabled { duration: 60 }
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_status_update_expands_to_nothing() -> Result<()> {
|
||||
let alice = TestContext::new_alice().await;
|
||||
let bob = TestContext::new_bob().await;
|
||||
let chat_id = alice.create_chat(&bob).await.id;
|
||||
|
||||
let file = alice.get_blobdir().join("minimal.xdc");
|
||||
tokio::fs::write(&file, include_bytes!("../test-data/webxdc/minimal.xdc")).await?;
|
||||
let mut instance = Message::new(Viewtype::File);
|
||||
instance.set_file_and_deduplicate(&alice, &file, None, None)?;
|
||||
let _sent1 = alice.send_msg(chat_id, &mut instance).await;
|
||||
|
||||
alice
|
||||
.send_webxdc_status_update(instance.id, r#"{"payload":7}"#)
|
||||
.await?;
|
||||
alice.flush_status_updates().await?;
|
||||
let sent2 = alice.pop_sent_msg().await;
|
||||
let sent2_rfc724_mid = sent2.load_from_db().await.rfc724_mid;
|
||||
|
||||
// not downloading the status update results in an placeholder
|
||||
receive_imf_from_inbox(
|
||||
&bob,
|
||||
&sent2_rfc724_mid,
|
||||
sent2.payload().as_bytes(),
|
||||
false,
|
||||
Some(sent2.payload().len() as u32),
|
||||
)
|
||||
.await?;
|
||||
let msg = bob.get_last_msg().await;
|
||||
let chat_id = msg.chat_id;
|
||||
assert_eq!(
|
||||
get_chat_msgs(&bob, chat_id).await?.len(),
|
||||
E2EE_INFO_MSGS + 1
|
||||
);
|
||||
assert_eq!(msg.download_state(), DownloadState::Available);
|
||||
|
||||
// downloading the status update afterwards expands to nothing and moves the placeholder to trash-chat
|
||||
// (usually status updates are too small for not being downloaded directly)
|
||||
receive_imf_from_inbox(
|
||||
&bob,
|
||||
&sent2_rfc724_mid,
|
||||
sent2.payload().as_bytes(),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(get_chat_msgs(&bob, chat_id).await?.len(), E2EE_INFO_MSGS);
|
||||
assert!(
|
||||
Message::load_from_db_optional(&bob, msg.id)
|
||||
.await?
|
||||
.is_none()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_mdn_expands_to_nothing() -> Result<()> {
|
||||
let bob = TestContext::new_bob().await;
|
||||
let raw = b"Subject: Message opened\n\
|
||||
Date: Mon, 10 Jan 2020 00:00:00 +0000\n\
|
||||
Chat-Version: 1.0\n\
|
||||
Message-ID: <bar@example.org>\n\
|
||||
To: Alice <alice@example.org>\n\
|
||||
From: Bob <bob@example.org>\n\
|
||||
Content-Type: multipart/report; report-type=disposition-notification;\n\t\
|
||||
boundary=\"kJBbU58X1xeWNHgBtTbMk80M5qnV4N\"\n\
|
||||
\n\
|
||||
\n\
|
||||
--kJBbU58X1xeWNHgBtTbMk80M5qnV4N\n\
|
||||
Content-Type: text/plain; charset=utf-8\n\
|
||||
\n\
|
||||
bla\n\
|
||||
\n\
|
||||
\n\
|
||||
--kJBbU58X1xeWNHgBtTbMk80M5qnV4N\n\
|
||||
Content-Type: message/disposition-notification\n\
|
||||
\n\
|
||||
Reporting-UA: Delta Chat 1.88.0\n\
|
||||
Original-Recipient: rfc822;bob@example.org\n\
|
||||
Final-Recipient: rfc822;bob@example.org\n\
|
||||
Original-Message-ID: <foo@example.org>\n\
|
||||
Disposition: manual-action/MDN-sent-automatically; displayed\n\
|
||||
\n\
|
||||
\n\
|
||||
--kJBbU58X1xeWNHgBtTbMk80M5qnV4N--\n\
|
||||
";
|
||||
|
||||
// not downloading the mdn results in an placeholder
|
||||
receive_imf_from_inbox(&bob, "bar@example.org", raw, false, Some(raw.len() as u32)).await?;
|
||||
let msg = bob.get_last_msg().await;
|
||||
let chat_id = msg.chat_id;
|
||||
assert_eq!(get_chat_msgs(&bob, chat_id).await?.len(), 1);
|
||||
assert_eq!(msg.download_state(), DownloadState::Available);
|
||||
|
||||
// downloading the mdn afterwards expands to nothing and deletes the placeholder directly
|
||||
// (usually mdn are too small for not being downloaded directly)
|
||||
receive_imf_from_inbox(&bob, "bar@example.org", raw, false, None).await?;
|
||||
assert_eq!(get_chat_msgs(&bob, chat_id).await?.len(), 0);
|
||||
assert!(
|
||||
Message::load_from_db_optional(&bob, msg.id)
|
||||
.await?
|
||||
.is_none()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that fully downloading the message
|
||||
/// works even if the Message-ID already exists
|
||||
/// in the database assigned to the trash chat.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_partial_download_trashed() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
|
||||
let imf_raw = b"From: Bob <bob@example.org>\n\
|
||||
To: Alice <alice@example.org>\n\
|
||||
Chat-Version: 1.0\n\
|
||||
Subject: subject\n\
|
||||
Message-ID: <first@example.org>\n\
|
||||
Date: Sun, 14 Nov 2021 00:10:00 +0000\
|
||||
Content-Type: text/plain";
|
||||
|
||||
// Download message from Bob partially.
|
||||
let partial_received_msg =
|
||||
receive_imf_from_inbox(alice, "first@example.org", imf_raw, false, Some(100000))
|
||||
.await?
|
||||
.unwrap();
|
||||
assert_eq!(partial_received_msg.msg_ids.len(), 1);
|
||||
|
||||
// Delete the received message.
|
||||
// Not it is still in the database,
|
||||
// but in the trash chat.
|
||||
delete_msgs(alice, &[partial_received_msg.msg_ids[0]]).await?;
|
||||
|
||||
// Fully download message after deletion.
|
||||
let full_received_msg =
|
||||
receive_imf_from_inbox(alice, "first@example.org", imf_raw, false, None).await?;
|
||||
|
||||
// The message does not reappear.
|
||||
// However, `receive_imf` should not fail.
|
||||
assert!(full_received_msg.is_none());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
259
src/download/post_msg_metadata.rs
Normal file
259
src/download/post_msg_metadata.rs
Normal file
@@ -0,0 +1,259 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
use num_traits::ToPrimitive;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::context::Context;
|
||||
use crate::log::warn;
|
||||
use crate::message::Message;
|
||||
use crate::message::Viewtype;
|
||||
use crate::param::{Param, Params};
|
||||
|
||||
/// Metadata contained in Pre-Message that describes the Post-Message.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PostMsgMetadata {
|
||||
/// size of the attachment in bytes
|
||||
pub(crate) size: u64,
|
||||
/// Real viewtype of message
|
||||
pub(crate) viewtype: Viewtype,
|
||||
/// the original file name
|
||||
pub(crate) filename: String,
|
||||
/// Width and height of the image or video
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) wh: Option<(i32, i32)>,
|
||||
/// Duration of audio file or video in milliseconds
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) duration: Option<i32>,
|
||||
}
|
||||
|
||||
impl PostMsgMetadata {
|
||||
/// Returns `PostMsgMetadata` for messages with file attachment and `None` otherwise.
|
||||
pub(crate) async fn from_msg(context: &Context, message: &Message) -> Result<Option<Self>> {
|
||||
if !message.viewtype.has_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let size = message
|
||||
.get_filebytes(context)
|
||||
.await?
|
||||
.context("Unexpected: file has no size")?;
|
||||
let filename = message
|
||||
.param
|
||||
.get(Param::Filename)
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
let filename = match message.viewtype {
|
||||
Viewtype::Webxdc => message
|
||||
.get_webxdc_info(context)
|
||||
.await
|
||||
.map(|info| info.name)
|
||||
.unwrap_or_else(|_| filename),
|
||||
_ => filename,
|
||||
};
|
||||
let wh = {
|
||||
match (
|
||||
message.param.get_int(Param::Width),
|
||||
message.param.get_int(Param::Height),
|
||||
) {
|
||||
(None, None) => None,
|
||||
(Some(width), Some(height)) => Some((width, height)),
|
||||
wh => {
|
||||
warn!(
|
||||
context,
|
||||
"Message {} misses width or height: {:?}.", message.id, wh
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let duration = message.param.get_int(Param::Duration);
|
||||
|
||||
Ok(Some(Self {
|
||||
size,
|
||||
filename,
|
||||
viewtype: message.viewtype,
|
||||
wh,
|
||||
duration,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn to_header_value(&self) -> Result<String> {
|
||||
Ok(serde_json::to_string(&self)?)
|
||||
}
|
||||
|
||||
pub(crate) fn try_from_header_value(value: &str) -> Result<Self> {
|
||||
Ok(serde_json::from_str(value)?)
|
||||
}
|
||||
}
|
||||
|
||||
impl Params {
|
||||
/// Applies data from post_msg_metadata to Params
|
||||
pub(crate) fn apply_post_msg_metadata(
|
||||
&mut self,
|
||||
post_msg_metadata: &PostMsgMetadata,
|
||||
) -> &mut Self {
|
||||
self.set(Param::PostMessageFileBytes, post_msg_metadata.size);
|
||||
if !post_msg_metadata.filename.is_empty() {
|
||||
self.set(Param::Filename, &post_msg_metadata.filename);
|
||||
}
|
||||
self.set_i64(
|
||||
Param::PostMessageViewtype,
|
||||
post_msg_metadata.viewtype.to_i64().unwrap_or_default(),
|
||||
);
|
||||
if let Some((width, height)) = post_msg_metadata.wh {
|
||||
self.set(Param::Width, width);
|
||||
self.set(Param::Height, height);
|
||||
}
|
||||
if let Some(duration) = post_msg_metadata.duration {
|
||||
self.set(Param::Duration, duration);
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use anyhow::Result;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use crate::{
|
||||
message::{Message, Viewtype},
|
||||
test_utils::{TestContextManager, create_test_image},
|
||||
};
|
||||
|
||||
use super::PostMsgMetadata;
|
||||
|
||||
/// Build from message with file attachment
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_build_from_file_msg() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
|
||||
let mut file_msg = Message::new(Viewtype::File);
|
||||
file_msg.set_file_from_bytes(alice, "test.bin", &vec![0u8; 1_000_000], None)?;
|
||||
let post_msg_metadata = PostMsgMetadata::from_msg(alice, &file_msg).await?;
|
||||
assert_eq!(
|
||||
post_msg_metadata,
|
||||
Some(PostMsgMetadata {
|
||||
size: 1_000_000,
|
||||
viewtype: Viewtype::File,
|
||||
filename: "test.bin".to_string(),
|
||||
wh: None,
|
||||
duration: None,
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build from message with image attachment
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_build_from_image_msg() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let mut image_msg = Message::new(Viewtype::Image);
|
||||
|
||||
let (width, height) = (1080, 1920);
|
||||
let test_img = create_test_image(width, height)?;
|
||||
image_msg.set_file_from_bytes(alice, "vacation.png", &test_img, None)?;
|
||||
// this is usually done while sending,
|
||||
// but we don't send it here, so we need to call it ourself
|
||||
image_msg.try_calc_and_set_dimensions(alice).await?;
|
||||
let post_msg_metadata = PostMsgMetadata::from_msg(alice, &image_msg).await?;
|
||||
assert_eq!(
|
||||
post_msg_metadata,
|
||||
Some(PostMsgMetadata {
|
||||
size: 1816098,
|
||||
viewtype: Viewtype::Image,
|
||||
filename: "vacation.png".to_string(),
|
||||
wh: Some((width as i32, height as i32)),
|
||||
duration: None,
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that serialisation results in expected format
|
||||
#[test]
|
||||
fn test_serialize_to_header() -> Result<()> {
|
||||
assert_eq!(
|
||||
PostMsgMetadata {
|
||||
size: 1_000_000,
|
||||
viewtype: Viewtype::File,
|
||||
filename: "test.bin".to_string(),
|
||||
wh: None,
|
||||
duration: None,
|
||||
}
|
||||
.to_header_value()?,
|
||||
"{\"size\":1000000,\"viewtype\":\"File\",\"filename\":\"test.bin\"}"
|
||||
);
|
||||
assert_eq!(
|
||||
PostMsgMetadata {
|
||||
size: 5_342_765,
|
||||
viewtype: Viewtype::Image,
|
||||
filename: "vacation.png".to_string(),
|
||||
wh: Some((1080, 1920)),
|
||||
duration: None,
|
||||
}
|
||||
.to_header_value()?,
|
||||
"{\"size\":5342765,\"viewtype\":\"Image\",\"filename\":\"vacation.png\",\"wh\":[1080,1920]}"
|
||||
);
|
||||
assert_eq!(
|
||||
PostMsgMetadata {
|
||||
size: 5_000,
|
||||
viewtype: Viewtype::Audio,
|
||||
filename: "audio-DD-MM-YY.ogg".to_string(),
|
||||
wh: None,
|
||||
duration: Some(152_310),
|
||||
}
|
||||
.to_header_value()?,
|
||||
"{\"size\":5000,\"viewtype\":\"Audio\",\"filename\":\"audio-DD-MM-YY.ogg\",\"duration\":152310}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that deserialisation from expected format works
|
||||
/// This test will become important for compatibility between versions in the future
|
||||
#[test]
|
||||
fn test_deserialize_from_header() -> Result<()> {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<PostMsgMetadata>(
|
||||
"{\"size\":1000000,\"viewtype\":\"File\",\"filename\":\"test.bin\",\"wh\":null,\"duration\":null}"
|
||||
)?,
|
||||
PostMsgMetadata {
|
||||
size: 1_000_000,
|
||||
viewtype: Viewtype::File,
|
||||
filename: "test.bin".to_string(),
|
||||
wh: None,
|
||||
duration: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<PostMsgMetadata>(
|
||||
"{\"size\":5342765,\"viewtype\":\"Image\",\"filename\":\"vacation.png\",\"wh\":[1080,1920]}"
|
||||
)?,
|
||||
PostMsgMetadata {
|
||||
size: 5_342_765,
|
||||
viewtype: Viewtype::Image,
|
||||
filename: "vacation.png".to_string(),
|
||||
wh: Some((1080, 1920)),
|
||||
duration: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<PostMsgMetadata>(
|
||||
"{\"size\":5000,\"viewtype\":\"Audio\",\"filename\":\"audio-DD-MM-YY.ogg\",\"duration\":152310}"
|
||||
)?,
|
||||
PostMsgMetadata {
|
||||
size: 5_000,
|
||||
viewtype: Viewtype::Audio,
|
||||
filename: "audio-DD-MM-YY.ogg".to_string(),
|
||||
wh: None,
|
||||
duration: Some(152_310),
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
29
src/e2ee.rs
29
src/e2ee.rs
@@ -12,29 +12,23 @@ use crate::pgp::{self, SeipdVersion};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct EncryptHelper {
|
||||
pub prefer_encrypt: EncryptPreference,
|
||||
pub addr: String,
|
||||
pub public_key: SignedPublicKey,
|
||||
}
|
||||
|
||||
impl EncryptHelper {
|
||||
pub async fn new(context: &Context) -> Result<EncryptHelper> {
|
||||
let prefer_encrypt = EncryptPreference::Mutual;
|
||||
let addr = context.get_primary_self_addr().await?;
|
||||
let public_key = load_self_public_key(context).await?;
|
||||
|
||||
Ok(EncryptHelper {
|
||||
prefer_encrypt,
|
||||
addr,
|
||||
public_key,
|
||||
})
|
||||
Ok(EncryptHelper { addr, public_key })
|
||||
}
|
||||
|
||||
pub fn get_aheader(&self) -> Aheader {
|
||||
Aheader {
|
||||
addr: self.addr.clone(),
|
||||
public_key: self.public_key.clone(),
|
||||
prefer_encrypt: self.prefer_encrypt,
|
||||
prefer_encrypt: EncryptPreference::Mutual,
|
||||
verified: false,
|
||||
}
|
||||
}
|
||||
@@ -46,7 +40,6 @@ impl EncryptHelper {
|
||||
keyring: Vec<SignedPublicKey>,
|
||||
mail_to_encrypt: MimePart<'static>,
|
||||
compress: bool,
|
||||
anonymous_recipients: bool,
|
||||
seipd_version: SeipdVersion,
|
||||
) -> Result<String> {
|
||||
let sign_key = load_self_secret_key(context).await?;
|
||||
@@ -55,15 +48,8 @@ impl EncryptHelper {
|
||||
let cursor = Cursor::new(&mut raw_message);
|
||||
mail_to_encrypt.clone().write_part(cursor).ok();
|
||||
|
||||
let ctext = pgp::pk_encrypt(
|
||||
raw_message,
|
||||
keyring,
|
||||
sign_key,
|
||||
compress,
|
||||
anonymous_recipients,
|
||||
seipd_version,
|
||||
)
|
||||
.await?;
|
||||
let ctext =
|
||||
pgp::pk_encrypt(raw_message, keyring, sign_key, compress, seipd_version).await?;
|
||||
|
||||
Ok(ctext)
|
||||
}
|
||||
@@ -76,8 +62,13 @@ impl EncryptHelper {
|
||||
shared_secret: &str,
|
||||
mail_to_encrypt: MimePart<'static>,
|
||||
compress: bool,
|
||||
sign: bool,
|
||||
) -> Result<String> {
|
||||
let sign_key = load_self_secret_key(context).await?;
|
||||
let sign_key = if sign {
|
||||
Some(load_self_secret_key(context).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut raw_message = Vec::new();
|
||||
let cursor = Cursor::new(&mut raw_message);
|
||||
|
||||
@@ -474,8 +474,10 @@ pub(crate) async fn delete_expired_messages(context: &Context, now: i64) -> Resu
|
||||
// If you change which information is preserved here, also change `MsgId::trash()`
|
||||
// and other places it references.
|
||||
let mut del_msg_stmt = transaction.prepare(
|
||||
"INSERT OR REPLACE INTO msgs (id, rfc724_mid, timestamp, chat_id)
|
||||
SELECT ?1, rfc724_mid, timestamp, ? FROM msgs WHERE id=?1",
|
||||
"
|
||||
INSERT OR REPLACE INTO msgs (id, rfc724_mid, pre_rfc724_mid, timestamp, chat_id)
|
||||
SELECT ?1, rfc724_mid, pre_rfc724_mid, timestamp, ? FROM msgs WHERE id=?1
|
||||
",
|
||||
)?;
|
||||
let mut del_location_stmt =
|
||||
transaction.prepare("DELETE FROM locations WHERE independent=1 AND id=?")?;
|
||||
@@ -591,6 +593,7 @@ async fn next_expiration_timestamp(context: &Context) -> Option<i64> {
|
||||
.min()
|
||||
}
|
||||
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub(crate) async fn ephemeral_loop(context: &Context, interrupt_receiver: Receiver<()>) {
|
||||
loop {
|
||||
let ephemeral_timestamp = next_expiration_timestamp(context).await;
|
||||
@@ -648,6 +651,7 @@ pub(crate) async fn ephemeral_loop(context: &Context, interrupt_receiver: Receiv
|
||||
}
|
||||
|
||||
/// Schedules expired IMAP messages for deletion.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub(crate) async fn delete_expired_imap_messages(context: &Context) -> Result<()> {
|
||||
let now = time();
|
||||
|
||||
@@ -663,25 +667,19 @@ pub(crate) async fn delete_expired_imap_messages(context: &Context) -> Result<()
|
||||
now - max(delete_server_after, MIN_DELETE_SERVER_AFTER),
|
||||
),
|
||||
};
|
||||
let target = context.get_delete_msgs_target().await?;
|
||||
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"UPDATE imap
|
||||
SET target=?
|
||||
SET target=''
|
||||
WHERE rfc724_mid IN (
|
||||
SELECT rfc724_mid FROM msgs
|
||||
WHERE ((download_state = 0 AND timestamp < ?) OR
|
||||
(download_state != 0 AND timestamp < ?) OR
|
||||
(ephemeral_timestamp != 0 AND ephemeral_timestamp <= ?))
|
||||
)",
|
||||
(
|
||||
&target,
|
||||
threshold_timestamp,
|
||||
threshold_timestamp_extended,
|
||||
now,
|
||||
),
|
||||
(threshold_timestamp, threshold_timestamp_extended, now),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ impl EventEmitter {
|
||||
/// [`try_recv`]: Self::try_recv
|
||||
pub async fn recv(&self) -> Option<Event> {
|
||||
let mut lock = self.0.lock().await;
|
||||
match lock.recv().await {
|
||||
match lock.recv_direct().await {
|
||||
Err(async_broadcast::RecvError::Overflowed(n)) => Some(Event {
|
||||
id: 0,
|
||||
typ: EventType::EventChannelOverflow { n },
|
||||
@@ -107,6 +107,39 @@ impl EventEmitter {
|
||||
| Ok(_)) => Ok(res?),
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits until there is at least one event available
|
||||
/// and then returns a vector of at least one event.
|
||||
///
|
||||
/// Returns empty vector if the sender has been dropped.
|
||||
pub async fn recv_batch(&self) -> Vec<Event> {
|
||||
let mut lock = self.0.lock().await;
|
||||
let mut res = match lock.recv_direct().await {
|
||||
Err(async_broadcast::RecvError::Overflowed(n)) => vec![Event {
|
||||
id: 0,
|
||||
typ: EventType::EventChannelOverflow { n },
|
||||
}],
|
||||
Err(async_broadcast::RecvError::Closed) => return Vec::new(),
|
||||
Ok(event) => vec![event],
|
||||
};
|
||||
|
||||
// Return up to 100 events in a single batch
|
||||
// to have a limit on used memory if events arrive too fast.
|
||||
for _ in 0..100 {
|
||||
match lock.try_recv() {
|
||||
Err(async_broadcast::TryRecvError::Overflowed(n)) => res.push(Event {
|
||||
id: 0,
|
||||
typ: EventType::EventChannelOverflow { n },
|
||||
}),
|
||||
Ok(event) => res.push(event),
|
||||
Err(async_broadcast::TryRecvError::Empty)
|
||||
| Err(async_broadcast::TryRecvError::Closed) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
/// The event emitted by a [`Context`] from an [`EventEmitter`].
|
||||
|
||||
@@ -30,9 +30,10 @@ pub(crate) async fn emit_chatlist_item_changed_for_contact_chat(
|
||||
match ChatId::lookup_by_contact(context, contact_id).await {
|
||||
Ok(Some(chat_id)) => self::emit_chatlist_item_changed(context, chat_id),
|
||||
Ok(None) => {}
|
||||
Err(error) => context.emit_event(EventType::Error(format!(
|
||||
Err(error) => error!(
|
||||
context,
|
||||
"failed to find chat id for contact for chatlist event: {error:?}"
|
||||
))),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ pub enum EventType {
|
||||
/// should not be disturbed by a dialog or so. Instead, use a bubble or so.
|
||||
///
|
||||
/// However, for ongoing processes (eg. configure())
|
||||
/// or for functions that are expected to fail (eg. dc_continue_key_transfer())
|
||||
/// or for functions that are expected to fail
|
||||
/// it might be better to delay showing these events until the function has really
|
||||
/// failed (returned false). It should be sufficient to report only the *last* error
|
||||
/// in a message box then.
|
||||
@@ -243,7 +243,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>,
|
||||
@@ -253,7 +253,7 @@ pub enum EventType {
|
||||
///
|
||||
/// @param data1 (usize) 0=error, 1-999=progress in permille, 1000=success and done
|
||||
/// @param data2 0
|
||||
ImexProgress(usize),
|
||||
ImexProgress(u16),
|
||||
|
||||
/// A file has been exported. A file has been written by imex().
|
||||
/// This event may be sent multiple times by a single call to imex().
|
||||
@@ -280,7 +280,7 @@ pub enum EventType {
|
||||
chat_type: Chattype,
|
||||
|
||||
/// Progress, always 1000.
|
||||
progress: usize,
|
||||
progress: u16,
|
||||
},
|
||||
|
||||
/// Progress information of a secure-join handshake from the view of the joiner
|
||||
@@ -295,7 +295,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.
|
||||
@@ -397,6 +397,8 @@ pub enum EventType {
|
||||
msg_id: MsgId,
|
||||
/// ID of the chat which the message belongs to.
|
||||
chat_id: ChatId,
|
||||
/// The call was accepted from this device (process).
|
||||
from_this_device: bool,
|
||||
},
|
||||
|
||||
/// Outgoing call accepted.
|
||||
@@ -417,13 +419,13 @@ pub enum EventType {
|
||||
chat_id: ChatId,
|
||||
},
|
||||
|
||||
/// One or more transports has changed.
|
||||
/// One or more transports has changed or another transport is primary now.
|
||||
///
|
||||
/// This event is used for tests to detect when transport
|
||||
/// synchronization messages arrives.
|
||||
/// UIs don't need to use it, it is unlikely
|
||||
/// that user modifies transports on multiple
|
||||
/// devices simultaneously.
|
||||
/// UI should update the list.
|
||||
///
|
||||
/// This event is emitted when a transport
|
||||
/// synchronization message modifies transports,
|
||||
/// but not when the UI modifies the transport list by itself.
|
||||
TransportsModified,
|
||||
|
||||
/// Event for using in tests, e.g. as a fence between normally generated events.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user