mirror of
https://github.com/chatmail/core.git
synced 2026-04-06 07:32:12 +03:00
Compare commits
80 Commits
v2.43.0
...
wofwca/94a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c1ee5dc1a | ||
|
|
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 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -20,7 +20,7 @@ permissions: {}
|
||||
|
||||
env:
|
||||
RUSTFLAGS: -Dwarnings
|
||||
RUST_VERSION: 1.93.0
|
||||
RUST_VERSION: 1.94.0
|
||||
|
||||
# Minimum Supported Rust Version
|
||||
MSRV: 1.88.0
|
||||
|
||||
14
.github/workflows/deltachat-rpc-server.yml
vendored
14
.github/workflows/deltachat-rpc-server.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # 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
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # 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
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
|
||||
- name: Build deltachat-rpc-server binaries
|
||||
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # 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
|
||||
@@ -157,7 +157,7 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # 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
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # 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
|
||||
@@ -208,7 +208,7 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
|
||||
- name: Download Linux aarch64 binary
|
||||
uses: actions/download-artifact@v7
|
||||
|
||||
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@4e002c8ec80594ecd40e759629461e26c8abed15 # 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@4e002c8ec80594ecd40e759629461e26c8abed15 # 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@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
- run: nix build .#${{ matrix.installable }}
|
||||
|
||||
2
.github/workflows/repl.yml
vendored
2
.github/workflows/repl.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
- name: Build
|
||||
run: nix build .#deltachat-repl-win64
|
||||
- name: Upload binary
|
||||
|
||||
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@4e002c8ec80594ecd40e759629461e26c8abed15 # 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@4e002c8ec80594ecd40e759629461e26c8abed15 # 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@eac588ad8def6316056a12d4907a9d4d84ff7a3b
|
||||
|
||||
- name: Run zizmor
|
||||
run: uvx zizmor --format sarif . > results.sarif
|
||||
|
||||
- name: Upload SARIF file
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
category: zizmor
|
||||
uses: zizmorcore/zizmor-action@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0
|
||||
|
||||
6
.github/zizmor.yml
vendored
Normal file
6
.github/zizmor.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
rules:
|
||||
unpinned-uses:
|
||||
config:
|
||||
policies:
|
||||
actions/*: ref-pin
|
||||
dependabot/*: ref-pin
|
||||
56
CHANGELOG.md
56
CHANGELOG.md
@@ -1,5 +1,58 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
@@ -403,7 +456,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
|
||||
|
||||
@@ -7767,3 +7820,4 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
|
||||
[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
|
||||
|
||||
286
Cargo.lock
generated
286
Cargo.lock
generated
@@ -124,12 +124,9 @@ checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc"
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.100"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
]
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
@@ -180,7 +177,7 @@ checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -192,7 +189,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -274,9 +271,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-imap"
|
||||
version = "0.11.1"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8da885da5980f3934831e6370445c0e0e44ef251d7792308b39e908915a41d09"
|
||||
checksum = "a78dceaba06f029d8f4d7df20addd4b7370a30206e3926267ecda2915b0f3f66"
|
||||
dependencies = [
|
||||
"async-channel 2.5.0",
|
||||
"async-compression",
|
||||
@@ -308,12 +305,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-native-tls"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9343dc5acf07e79ff82d0c37899f079db3534d99f189a1837c8e549c99405bec"
|
||||
checksum = "37dd6b179962fe4048a6f81d4c0d7ed419a21fdf49204b4c6b04971693358e79"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
@@ -342,7 +339,7 @@ checksum = "644dd749086bf3771a2fbc5f256fdb982d53f011c7d5d560304eafeecebce79d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -461,7 +458,7 @@ checksum = "57413e4b276d883b77fb368b7b33ae6a5eb97692852d49a5394d4f72ba961827"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
@@ -473,9 +470,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.9.1"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "bitvec"
|
||||
@@ -601,7 +598,7 @@ dependencies = [
|
||||
"proc-macro-crate 2.0.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1039,9 +1036,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "criterion"
|
||||
version = "0.8.1"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d883447757bb0ee46f233e9dc22eb84d93a9508c9b868687b274fc431d886bf"
|
||||
checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
|
||||
dependencies = [
|
||||
"alloca",
|
||||
"anes",
|
||||
@@ -1065,9 +1062,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "criterion-plot"
|
||||
version = "0.8.1"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed943f81ea2faa8dcecbbfa50164acf95d555afec96a27871663b300e387b2e4"
|
||||
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
|
||||
dependencies = [
|
||||
"cast",
|
||||
"itertools",
|
||||
@@ -1119,7 +1116,7 @@ version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"crossterm_winapi",
|
||||
"parking_lot",
|
||||
"rustix 0.38.44",
|
||||
@@ -1231,7 +1228,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1272,7 +1269,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1283,7 +1280,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1303,7 +1300,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat"
|
||||
version = "2.43.0"
|
||||
version = "2.44.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -1371,8 +1368,8 @@ dependencies = [
|
||||
"sha2",
|
||||
"shadowsocks",
|
||||
"smallvec",
|
||||
"strum 0.27.2",
|
||||
"strum_macros 0.27.2",
|
||||
"strum 0.28.0",
|
||||
"strum_macros 0.28.0",
|
||||
"tagger",
|
||||
"tempfile",
|
||||
"testdir",
|
||||
@@ -1413,7 +1410,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat-jsonrpc"
|
||||
version = "2.43.0"
|
||||
version = "2.44.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel 2.5.0",
|
||||
@@ -1434,7 +1431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat-repl"
|
||||
version = "2.43.0"
|
||||
version = "2.44.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deltachat",
|
||||
@@ -1450,7 +1447,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat-rpc-server"
|
||||
version = "2.43.0"
|
||||
version = "2.44.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deltachat",
|
||||
@@ -1474,12 +1471,12 @@ name = "deltachat_derive"
|
||||
version = "2.0.0"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deltachat_ffi"
|
||||
version = "2.43.0"
|
||||
version = "2.44.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deltachat",
|
||||
@@ -1528,7 +1525,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1558,7 +1555,7 @@ dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1568,7 +1565,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
|
||||
dependencies = [
|
||||
"derive_builder_core",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1597,7 +1594,7 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
@@ -1609,7 +1606,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
@@ -1675,7 +1672,7 @@ checksum = "487585f4d0c6655fe74905e2504d8ad6908e4db67f744eb140876906c2f3175d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1738,7 +1735,7 @@ checksum = "7a4102713839a8c01c77c165bc38ef2e83948f6397fa1e1dcfacec0f07b149d3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1867,7 +1864,7 @@ dependencies = [
|
||||
"heck 0.4.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1887,7 +1884,7 @@ checksum = "fc4caf64a58d7a6d65ab00639b046ff54399a39f5f2554728895ace4b297cd79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1997,7 +1994,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rustix 1.1.3",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
@@ -2104,9 +2101,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
@@ -2132,9 +2129,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
@@ -2157,15 +2154,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
|
||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
@@ -2174,9 +2171,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-lite"
|
||||
@@ -2193,32 +2190,32 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
@@ -2228,7 +2225,6 @@ dependencies = [
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"slab",
|
||||
]
|
||||
|
||||
@@ -2640,13 +2636,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.19"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http 1.1.0",
|
||||
"http-body",
|
||||
@@ -2797,7 +2792,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3087,7 +3082,7 @@ dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3259,9 +3254,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.180"
|
||||
version = "0.2.182"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
@@ -3275,7 +3270,7 @@ version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"libc",
|
||||
"redox_syscall 0.5.12",
|
||||
]
|
||||
@@ -3309,9 +3304,9 @@ checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
@@ -3368,6 +3363,12 @@ version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "227748d55f2f0ab4735d87fd623798cb6b664512fe979705f829c9f81c934465"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "lru_time_cache"
|
||||
version = "0.11.11"
|
||||
@@ -3589,7 +3590,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0800eae8638a299eaa67476e1c6b6692922273e0f7939fd188fc861c837b9cd2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"byteorder",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -3684,7 +3685,7 @@ version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -3789,7 +3790,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3850,7 +3851,7 @@ dependencies = [
|
||||
"proc-macro-crate 3.2.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3859,7 +3860,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3930,7 +3931,7 @@ version = "0.10.72"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
@@ -3947,7 +3948,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4134,7 +4135,7 @@ dependencies = [
|
||||
"pest_meta",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4243,7 +4244,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4362,7 +4363,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4392,7 +4393,7 @@ version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
@@ -4594,7 +4595,7 @@ dependencies = [
|
||||
"proc-macro-error-attr2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4608,11 +4609,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proptest"
|
||||
version = "1.9.0"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40"
|
||||
checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"num-traits",
|
||||
"rand 0.9.2",
|
||||
"rand_chacha 0.9.0",
|
||||
@@ -4660,9 +4661,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.0"
|
||||
version = "0.39.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2e3bf4aa9d243beeb01a7b3bc30b77cfe2c44e24ec02d751a7104a53c2c49a1"
|
||||
checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -4687,13 +4688,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.9"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.2.16",
|
||||
"rand 0.8.5",
|
||||
"getrandom 0.3.3",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -4895,7 +4897,7 @@ version = "0.5.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4911,9 +4913,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.2"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
@@ -5082,7 +5084,7 @@ version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
@@ -5126,7 +5128,7 @@ version = "0.38.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.14",
|
||||
@@ -5135,14 +5137,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.3"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.11.0",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.1",
|
||||
]
|
||||
|
||||
@@ -5203,7 +5205,7 @@ version = "16.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62fd9ca5ebc709e8535e8ef7c658eb51457987e48c98ead2be482172accc408d"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"clipboard-win",
|
||||
"fd-lock",
|
||||
@@ -5282,7 +5284,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5330,7 +5332,7 @@ version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
@@ -5414,7 +5416,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5425,7 +5427,7 @@ checksum = "330f01ce65a3a5fe59a60c82f3c9a024b573b8a6e875bd233fe5f934e71d54e3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5649,7 +5651,7 @@ version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dee851d0e5e7af3721faea1843e8015e820a234f81fda3dea9247e15bac9a86a"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5691,7 +5693,7 @@ dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5774,9 +5776,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.27.2"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
|
||||
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
@@ -5788,19 +5790,19 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustversion",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.27.2"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
|
||||
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5871,9 +5873,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.114"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5897,7 +5899,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5934,7 +5936,7 @@ version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
@@ -5969,14 +5971,14 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.24.0"
|
||||
version = "3.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
|
||||
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.3",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.1",
|
||||
]
|
||||
|
||||
@@ -6032,7 +6034,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6043,7 +6045,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6158,7 +6160,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6358,7 +6360,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6408,7 +6410,7 @@ checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6453,7 +6455,7 @@ dependencies = [
|
||||
"proc-macro-error2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6654,7 +6656,7 @@ dependencies = [
|
||||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
@@ -6689,7 +6691,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
@@ -6872,7 +6874,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6883,7 +6885,7 @@ checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6894,7 +6896,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6905,7 +6907,7 @@ checksum = "cb26fd936d991781ea39e87c3a27285081e3c0da5ca0fcbc02d368cc6f52ff01"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7217,7 +7219,7 @@ version = "0.39.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7364,7 +7366,7 @@ dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7387,7 +7389,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -7415,7 +7417,7 @@ checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7435,7 +7437,7 @@ checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -7456,7 +7458,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7478,7 +7480,7 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
14
Cargo.toml
14
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat"
|
||||
version = "2.43.0"
|
||||
version = "2.44.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 }
|
||||
@@ -96,8 +96,8 @@ sha-1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
shadowsocks = { version = "1.23.1", default-features = false, features = ["aead-cipher", "aead-cipher-2022"] }
|
||||
smallvec = "1.15.1"
|
||||
strum = "0.27"
|
||||
strum_macros = "0.27"
|
||||
strum = "0.28"
|
||||
strum_macros = "0.28"
|
||||
tagger = "4.3.4"
|
||||
textwrap = "0.16.2"
|
||||
thiserror = { workspace = true }
|
||||
@@ -186,7 +186,7 @@ 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"
|
||||
@@ -194,12 +194,12 @@ 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.24.0"
|
||||
tempfile = "3.25.0"
|
||||
thiserror = "2"
|
||||
tokio = "1"
|
||||
tokio-util = "0.7.18"
|
||||
|
||||
38
RELEASE.md
38
RELEASE.md
@@ -22,6 +22,44 @@ For example, to release version 1.116.0 of the core, do the following steps.
|
||||
|
||||
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,
|
||||
|
||||
@@ -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.to_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.43.0"
|
||||
version = "2.44.0-dev"
|
||||
description = "Deltachat FFI"
|
||||
edition = "2018"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -4612,6 +4612,7 @@ int dc_msg_is_info (const dc_msg_t* msg);
|
||||
* and also offer a way to fix the encryption, eg. by a button offering a QR scan
|
||||
* - DC_INFO_WEBXDC_INFO_MESSAGE (32) - Info-message created by webxdc app sending `update.info`
|
||||
* - DC_INFO_CHAT_E2EE (50) - Info-message for "Chat is end-to-end-encrypted"
|
||||
* - DC_INFO_GROUP_DESCRIPTION_CHANGED (70) - Info-message "Description changed", UI should open the profile with the description
|
||||
*
|
||||
* For the messages that refer to a CONTACT,
|
||||
* dc_msg_get_info_contact_id() returns the contact ID.
|
||||
@@ -4667,6 +4668,7 @@ uint32_t dc_msg_get_info_contact_id (const dc_msg_t* msg);
|
||||
#define DC_INFO_INVALID_UNENCRYPTED_MAIL 13
|
||||
#define DC_INFO_WEBXDC_INFO_MESSAGE 32
|
||||
#define DC_INFO_CHAT_E2EE 50
|
||||
#define DC_INFO_GROUP_DESCRIPTION_CHANGED 70
|
||||
|
||||
|
||||
/**
|
||||
@@ -7494,7 +7496,7 @@ void dc_event_unref(dc_event_t* event);
|
||||
|
||||
/// "Messages are end-to-end encrypted."
|
||||
///
|
||||
/// Used in info messages.
|
||||
/// Used in info-messages, UI may add smth. as "Tap to learn more."
|
||||
#define DC_STR_CHAT_PROTECTION_ENABLED 170
|
||||
|
||||
/// "Others will only see this group after you sent a first message."
|
||||
@@ -7577,6 +7579,19 @@ void dc_event_unref(dc_event_t* event);
|
||||
/// `%1$s` and `%2$s` will both be replaced by the name of the inviter.
|
||||
#define DC_STR_SECURE_JOIN_CHANNEL_STARTED 203
|
||||
|
||||
/// "Channel name changed from %1$s to %2$s."
|
||||
///
|
||||
/// Used in status messages.
|
||||
///
|
||||
/// `%1$s` will be replaced by the old channel name.
|
||||
/// `%2$s` will be replaced by the new channel name.
|
||||
#define DC_STR_CHANNEL_NAME_CHANGED 204
|
||||
|
||||
/// "Channel image changed."
|
||||
///
|
||||
/// Used in status messages.
|
||||
#define DC_STR_CHANNEL_IMAGE_CHANGED 205
|
||||
|
||||
/// "The attachment contains anonymous usage statistics, which help us improve Delta Chat. Thank you!"
|
||||
///
|
||||
/// Used as the message body for statistics sent out.
|
||||
@@ -7609,6 +7624,17 @@ void dc_event_unref(dc_event_t* event);
|
||||
/// "Incoming video call"
|
||||
#define DC_STR_INCOMING_VIDEO_CALL 235
|
||||
|
||||
/// "You changed the chat description."
|
||||
#define DC_STR_GROUP_DESCRIPTION_CHANGED_BY_YOU 240
|
||||
|
||||
/// "Chat description changed by %1$s."
|
||||
#define DC_STR_GROUP_DESCRIPTION_CHANGED_BY_OTHER 241
|
||||
|
||||
/// "Messages are end-to-end encrypted."
|
||||
///
|
||||
/// Used when creating text for the "Encryption Info" dialogs.
|
||||
#define DC_STR_MESSAGES_ARE_E2EE 242
|
||||
|
||||
/**
|
||||
* @}
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,7 @@ use std::collections::BTreeMap;
|
||||
use std::convert::TryFrom;
|
||||
use std::fmt::Write;
|
||||
use std::future::Future;
|
||||
use std::mem::ManuallyDrop;
|
||||
use std::ptr;
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
@@ -5150,10 +5151,10 @@ pub unsafe extern "C" fn dc_jsonrpc_init(
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
let account_manager = Arc::from_raw(account_manager);
|
||||
let cmd_api = block_on(deltachat_jsonrpc::api::CommandApi::from_arc(
|
||||
account_manager.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.43.0"
|
||||
version = "2.44.0-dev"
|
||||
description = "DeltaChat JSON-RPC API"
|
||||
edition = "2021"
|
||||
license = "MPL-2.0"
|
||||
|
||||
@@ -31,7 +31,7 @@ use deltachat::peer_channels::{
|
||||
};
|
||||
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;
|
||||
@@ -194,6 +194,16 @@ impl CommandApi {
|
||||
.context("event channel is closed")
|
||||
}
|
||||
|
||||
/// Waits for at least one event and return a batch of events.
|
||||
async fn get_next_event_batch(&self) -> Vec<Event> {
|
||||
self.event_emitter
|
||||
.recv_batch()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|event| event.into())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------
|
||||
// Account Management
|
||||
// ---------------------------------------------
|
||||
@@ -854,6 +864,8 @@ impl CommandApi {
|
||||
/// if `checkQr()` returns `askVerifyContact` or `askVerifyGroup`
|
||||
/// an out-of-band-verification can be joined using `secure_join()`
|
||||
///
|
||||
/// @deprecated as of 2026-03; use create_qr_svg(get_chat_securejoin_qr_code()) instead.
|
||||
///
|
||||
/// chat_id: If set to a group-chat-id,
|
||||
/// the Verified-Group-Invite protocol is offered in the QR code;
|
||||
/// works for protected groups as well as for normal groups.
|
||||
@@ -1970,6 +1982,8 @@ impl CommandApi {
|
||||
/// even if there is no concurrent call to [`CommandApi::provide_backup`],
|
||||
/// but will fail after 60 seconds to avoid deadlocks.
|
||||
///
|
||||
/// @deprecated as of 2026-03; use `create_qr_svg(get_backup_qr())` instead.
|
||||
///
|
||||
/// Returns the QR code rendered as an SVG image.
|
||||
async fn get_backup_qr_svg(&self, account_id: u32) -> Result<String> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
@@ -1983,6 +1997,11 @@ impl CommandApi {
|
||||
generate_backup_qr(&ctx, &qr).await
|
||||
}
|
||||
|
||||
/// Renders the given text as a QR code SVG image.
|
||||
async fn create_qr_svg(&self, text: String) -> Result<String> {
|
||||
create_qr_svg(&text)
|
||||
}
|
||||
|
||||
/// Gets a backup from a remote provider.
|
||||
///
|
||||
/// This retrieves the backup from a remote device over the network and imports it into
|
||||
@@ -2496,7 +2515,10 @@ impl CommandApi {
|
||||
continue;
|
||||
}
|
||||
let sticker_name = sticker_entry.file_name().into_string().unwrap_or_default();
|
||||
if sticker_name.ends_with(".png") || sticker_name.ends_with(".webp") {
|
||||
if sticker_name.ends_with(".png")
|
||||
|| sticker_name.ends_with(".webp")
|
||||
|| sticker_name.ends_with(".gif")
|
||||
{
|
||||
sticker_paths.push(
|
||||
sticker_entry
|
||||
.path()
|
||||
|
||||
@@ -192,8 +192,7 @@ pub enum EventType {
|
||||
msg_id: u32,
|
||||
},
|
||||
|
||||
/// A single message is read by the receiver. State changed from DC_STATE_OUT_DELIVERED to
|
||||
/// DC_STATE_OUT_MDN_RCVD, see `Message.state`.
|
||||
/// A single message is read by a receiver.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
MsgRead {
|
||||
/// ID of the chat which the message belongs to.
|
||||
@@ -201,6 +200,12 @@ pub enum EventType {
|
||||
|
||||
/// ID of the message that was read.
|
||||
msg_id: u32,
|
||||
|
||||
/// Read for the first time (e.g. by just one group member
|
||||
/// / channel subscriber).
|
||||
/// State changed from DC_STATE_OUT_DELIVERED to
|
||||
/// DC_STATE_OUT_MDN_RCVD, see dc_msg_get_state().
|
||||
first_time: bool,
|
||||
},
|
||||
|
||||
/// A single message was deleted.
|
||||
@@ -540,9 +545,14 @@ impl From<CoreEventType> for EventType {
|
||||
chat_id: chat_id.to_u32(),
|
||||
msg_id: msg_id.to_u32(),
|
||||
},
|
||||
CoreEventType::MsgRead { chat_id, msg_id } => MsgRead {
|
||||
CoreEventType::MsgRead {
|
||||
chat_id,
|
||||
msg_id,
|
||||
first_time,
|
||||
} => MsgRead {
|
||||
chat_id: chat_id.to_u32(),
|
||||
msg_id: msg_id.to_u32(),
|
||||
first_time,
|
||||
},
|
||||
CoreEventType::MsgDeleted { chat_id, msg_id } => MsgDeleted {
|
||||
chat_id: chat_id.to_u32(),
|
||||
|
||||
@@ -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.43.0"
|
||||
"version": "2.44.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.43.0"
|
||||
version = "2.44.0-dev"
|
||||
license = "MPL-2.0"
|
||||
edition = "2021"
|
||||
repository = "https://github.com/chatmail/core"
|
||||
|
||||
@@ -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.43.0"
|
||||
version = "2.44.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",
|
||||
|
||||
@@ -54,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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -13,7 +13,7 @@ import pytest
|
||||
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:
|
||||
@@ -665,6 +665,24 @@ def test_openrpc_command_line() -> None:
|
||||
assert "methods" in openrpc
|
||||
|
||||
|
||||
def test_early_failure(tmp_path) -> None:
|
||||
"""Test that Rpc.start() raises on invalid accounts directories."""
|
||||
# A file instead of a directory.
|
||||
file_path = tmp_path / "not_a_dir"
|
||||
file_path.write_text("I am a file, not a directory")
|
||||
rpc = Rpc(accounts_dir=str(file_path))
|
||||
with pytest.raises(JsonRpcError, match="(?i)directory"):
|
||||
rpc.start()
|
||||
|
||||
# A non-empty directory that is not a deltachat accounts directory.
|
||||
non_dc_dir = tmp_path / "invalid_dir"
|
||||
non_dc_dir.mkdir()
|
||||
(non_dc_dir / "some_file").write_text("content")
|
||||
rpc = Rpc(accounts_dir=str(non_dc_dir))
|
||||
with pytest.raises(JsonRpcError, match="invalid_dir"):
|
||||
rpc.start()
|
||||
|
||||
|
||||
def test_provider_info(rpc) -> None:
|
||||
account_id = rpc.add_account()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-rpc-server"
|
||||
version = "2.43.0"
|
||||
version = "2.44.0-dev"
|
||||
description = "DeltaChat JSON-RPC server"
|
||||
edition = "2021"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "index.d.ts",
|
||||
"version": "2.43.0"
|
||||
"version": "2.44.0-dev"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "deltachat"
|
||||
version = "2.43.0"
|
||||
version = "2.44.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"
|
||||
|
||||
@@ -558,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 @@
|
||||
2026-02-17
|
||||
2026-02-27
|
||||
@@ -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.93.0
|
||||
RUST_VERSION=1.94.0
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
test -f "/lib/libc.musl-$ARCH.so.1" && LIBC=musl || LIBC=gnu
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -57,8 +57,8 @@ 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
|
||||
@@ -67,10 +67,9 @@ impl Accounts {
|
||||
/// 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 && !dir.exists() {
|
||||
Accounts::create(&dir).await?;
|
||||
if writable {
|
||||
Self::ensure_accounts_dir(&dir).await?;
|
||||
}
|
||||
|
||||
Accounts::open(events, dir, writable).await
|
||||
}
|
||||
|
||||
@@ -82,14 +81,20 @@ 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(())
|
||||
}
|
||||
|
||||
@@ -586,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?);
|
||||
@@ -752,6 +758,7 @@ impl Config {
|
||||
}
|
||||
|
||||
/// Creates a new account in the account manager directory.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn new_account(&mut self) -> Result<AccountConfig> {
|
||||
let id = {
|
||||
let id = self.inner.next_id;
|
||||
@@ -841,6 +848,7 @@ impl Config {
|
||||
///
|
||||
/// Without this workaround removing account may fail on Windows with an error
|
||||
/// "The process cannot access the file because it is being used by another process. (os error 32)".
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn try_many_times<F, Fut, T>(f: F) -> std::result::Result<(), T>
|
||||
where
|
||||
F: Fn() -> Fut,
|
||||
@@ -912,6 +920,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_account_new_empty_existing_dir() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p: PathBuf = dir.path().join("accounts");
|
||||
|
||||
// A non-empty directory without accounts.toml should fail.
|
||||
fs::create_dir_all(&p).await.unwrap();
|
||||
fs::write(p.join("stray_file.txt"), b"hello").await.unwrap();
|
||||
assert!(Accounts::new(p.clone(), true).await.is_err());
|
||||
|
||||
// Clean up to an empty directory.
|
||||
fs::remove_file(p.join("stray_file.txt")).await.unwrap();
|
||||
|
||||
// An empty directory without accounts.toml should succeed.
|
||||
let mut accounts = Accounts::new(p.clone(), true).await.unwrap();
|
||||
assert_eq!(accounts.accounts.len(), 0);
|
||||
let id = accounts.add_account().await.unwrap();
|
||||
assert_eq!(id, 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_account_new_open_conflict() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -47,11 +47,11 @@ pub struct Aheader {
|
||||
pub public_key: SignedPublicKey,
|
||||
pub prefer_encrypt: EncryptPreference,
|
||||
|
||||
// Whether `_verified` attribute is present.
|
||||
//
|
||||
// `_verified` attribute is an extension to `Autocrypt-Gossip`
|
||||
// header that is used to tell that the sender
|
||||
// marked this key as verified.
|
||||
/// Whether `_verified` attribute is present.
|
||||
///
|
||||
/// `_verified` attribute is an extension to `Autocrypt-Gossip`
|
||||
/// header that is used to tell that the sender
|
||||
/// marked this key as verified.
|
||||
pub verified: bool,
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ impl fmt::Display for Aheader {
|
||||
let keydata = self.public_key.to_base64().chars().enumerate().fold(
|
||||
String::new(),
|
||||
|mut res, (i, c)| {
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
if i % 78 == 78 - "keydata=".len() {
|
||||
res.push(' ')
|
||||
}
|
||||
@@ -107,13 +108,11 @@ impl FromStr for Aheader {
|
||||
.remove("keydata")
|
||||
.context("keydata attribute is not found")
|
||||
.and_then(|raw| {
|
||||
SignedPublicKey::from_base64(&raw).context("autocrypt key cannot be decoded")
|
||||
})
|
||||
.and_then(|key| {
|
||||
key.verify_bindings()
|
||||
.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")
|
||||
|
||||
@@ -321,6 +321,7 @@ 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,
|
||||
|
||||
@@ -79,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)
|
||||
}
|
||||
@@ -175,6 +176,7 @@ impl CallInfo {
|
||||
}
|
||||
|
||||
/// Returns call duration in seconds.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub fn duration_seconds(&self) -> i64 {
|
||||
if let (Some(start), Some(end)) = (
|
||||
self.msg.param.get_i64(CALL_ACCEPTED_TIMESTAMP),
|
||||
|
||||
115
src/chat.rs
115
src/chat.rs
@@ -1,7 +1,7 @@
|
||||
//! # Chat module.
|
||||
|
||||
use std::cmp;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::fmt;
|
||||
use std::io::Cursor;
|
||||
use std::marker::Sync;
|
||||
@@ -257,7 +257,11 @@ impl ChatId {
|
||||
ChatIdBlocked::get_for_contact(context, contact_id, create_blocked)
|
||||
.await
|
||||
.map(|chat| chat.id)?;
|
||||
ContactId::scaleup_origin(context, &[contact_id], Origin::CreateChat).await?;
|
||||
if create_blocked != Blocked::Yes {
|
||||
info!(context, "Scale up origin of {contact_id} to CreateChat.");
|
||||
ContactId::scaleup_origin(context, &[contact_id], Origin::CreateChat)
|
||||
.await?;
|
||||
}
|
||||
chat_id
|
||||
} else {
|
||||
warn!(
|
||||
@@ -471,7 +475,7 @@ impl ChatId {
|
||||
|
||||
/// Adds message "Messages are end-to-end encrypted".
|
||||
pub(crate) async fn add_e2ee_notice(self, context: &Context, timestamp: i64) -> Result<()> {
|
||||
let text = stock_str::messages_e2e_encrypted(context).await;
|
||||
let text = stock_str::messages_e2ee_info_msg(context).await;
|
||||
add_info_msg_with_cmd(
|
||||
context,
|
||||
self,
|
||||
@@ -941,6 +945,7 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
|
||||
/// Jaccard similarity coefficient is used to estimate similarity of chat member sets.
|
||||
///
|
||||
/// Chat is considered active if something was posted there within the last 42 days.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub async fn get_similar_chat_ids(self, context: &Context) -> Result<Vec<(ChatId, f64)>> {
|
||||
// Count number of common members in this and other chats.
|
||||
let intersection = context
|
||||
@@ -1145,13 +1150,14 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
|
||||
/// prefer plaintext emails.
|
||||
///
|
||||
/// To get more verbose summary for a contact, including its key fingerprint, use [`Contact::get_encrinfo`].
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub async fn get_encryption_info(self, context: &Context) -> Result<String> {
|
||||
let chat = Chat::load_from_db(context, self).await?;
|
||||
if !chat.is_encrypted(context).await? {
|
||||
return Ok(stock_str::encr_none(context).await);
|
||||
}
|
||||
|
||||
let mut ret = stock_str::messages_e2e_encrypted(context).await + "\n";
|
||||
let mut ret = stock_str::messages_are_e2ee(context).await + "\n";
|
||||
|
||||
for &contact_id in get_chat_contacts(context, self)
|
||||
.await?
|
||||
@@ -1730,6 +1736,7 @@ impl Chat {
|
||||
///
|
||||
/// If `update_msg_id` is set, that record is reused;
|
||||
/// if `update_msg_id` is None, a new record is created.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn prepare_msg_raw(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
@@ -1769,16 +1776,6 @@ impl Chat {
|
||||
.set_i64(Param::GroupNameTimestamp, msg.timestamp_sort)
|
||||
.set_i64(Param::GroupDescriptionTimestamp, msg.timestamp_sort);
|
||||
self.update_param(context).await?;
|
||||
// TODO: Remove this compat code needed because Core <= v1.143:
|
||||
// - doesn't accept synchronization of QR code tokens for unpromoted groups, so we also
|
||||
// send them when the group is promoted.
|
||||
// - doesn't sync QR code tokens for unpromoted groups and the group might be created
|
||||
// before an upgrade.
|
||||
context
|
||||
.sync_qr_code_tokens(Some(self.grpid.as_str()))
|
||||
.await
|
||||
.log_err(context)
|
||||
.ok();
|
||||
}
|
||||
|
||||
let is_bot = context.get_config_bool(Config::Bot).await?;
|
||||
@@ -2995,6 +2992,7 @@ pub async fn send_text_msg(
|
||||
}
|
||||
|
||||
/// Sends chat members a request to edit the given message's text.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub async fn send_edit_request(context: &Context, msg_id: MsgId, new_text: String) -> Result<()> {
|
||||
let mut original_msg = Message::load_from_db(context, msg_id).await?;
|
||||
ensure!(
|
||||
@@ -3100,6 +3098,7 @@ pub async fn get_chat_msgs(context: &Context, chat_id: ChatId) -> Result<Vec<Cha
|
||||
}
|
||||
|
||||
/// Returns messages belonging to the chat according to the given options.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub async fn get_chat_msgs_ex(
|
||||
context: &Context,
|
||||
chat_id: ChatId,
|
||||
@@ -3889,8 +3888,6 @@ pub(crate) async fn add_contact_to_chat_ex(
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let sync_qr_code_tokens;
|
||||
if from_handshake && chat.param.get_int(Param::Unpromoted).unwrap_or_default() == 1 {
|
||||
let smeared_time = smeared_time(context);
|
||||
chat.param
|
||||
@@ -3898,11 +3895,7 @@ pub(crate) async fn add_contact_to_chat_ex(
|
||||
.set_i64(Param::GroupNameTimestamp, smeared_time)
|
||||
.set_i64(Param::GroupDescriptionTimestamp, smeared_time);
|
||||
chat.update_param(context).await?;
|
||||
sync_qr_code_tokens = true;
|
||||
} else {
|
||||
sync_qr_code_tokens = false;
|
||||
}
|
||||
|
||||
if context.is_self_addr(contact.get_addr()).await? {
|
||||
// ourself is added using ContactId::SELF, do not add this address explicitly.
|
||||
// if SELF is not in the group, members cannot be added at all.
|
||||
@@ -3951,20 +3944,6 @@ pub(crate) async fn add_contact_to_chat_ex(
|
||||
send_msg(context, chat_id, &mut msg).await?;
|
||||
|
||||
sync = Nosync;
|
||||
// TODO: Remove this compat code needed because Core <= v1.143:
|
||||
// - doesn't accept synchronization of QR code tokens for unpromoted groups, so we also send
|
||||
// them when the group is promoted.
|
||||
// - doesn't sync QR code tokens for unpromoted groups and the group might be created before
|
||||
// an upgrade.
|
||||
if sync_qr_code_tokens
|
||||
&& context
|
||||
.sync_qr_code_tokens(Some(chat.grpid.as_str()))
|
||||
.await
|
||||
.log_err(context)
|
||||
.is_ok()
|
||||
{
|
||||
context.scheduler.interrupt_smtp().await;
|
||||
}
|
||||
}
|
||||
context.emit_event(EventType::ChatModified(chat_id));
|
||||
if sync.into() {
|
||||
@@ -3978,6 +3957,7 @@ pub(crate) async fn add_contact_to_chat_ex(
|
||||
/// This function does not check if the avatar is set.
|
||||
/// If avatar is not set and this function returns `true`,
|
||||
/// a `Chat-User-Avatar: 0` header should be sent to reset the avatar.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub(crate) async fn shall_attach_selfavatar(context: &Context, chat_id: ChatId) -> Result<bool> {
|
||||
let timestamp_some_days_ago = time() - DC_RESEND_USER_AVATAR_DAYS * 24 * 60 * 60;
|
||||
let needs_attach = context
|
||||
@@ -4242,20 +4222,19 @@ async fn set_chat_description_ex(
|
||||
bail!("Cannot set chat description; self not in group");
|
||||
}
|
||||
|
||||
let affected_rows = context
|
||||
let old_description = get_chat_description(context, chat_id).await?;
|
||||
if old_description == new_description {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"INSERT INTO chats_descriptions(chat_id, description) VALUES(?, ?)
|
||||
ON CONFLICT(chat_id) DO UPDATE
|
||||
SET description=excluded.description WHERE description<>excluded.description",
|
||||
"INSERT OR REPLACE INTO chats_descriptions(chat_id, description) VALUES(?, ?)",
|
||||
(chat_id, &new_description),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if affected_rows == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if chat.is_promoted() {
|
||||
let mut msg = Message::new(Viewtype::Text);
|
||||
msg.text = stock_str::msg_chat_description_changed(context, ContactId::SELF).await;
|
||||
@@ -4343,8 +4322,11 @@ async fn rename_ex(
|
||||
&& sanitize_single_line(&chat.name) != new_name
|
||||
{
|
||||
msg.viewtype = Viewtype::Text;
|
||||
msg.text =
|
||||
stock_str::msg_grp_name(context, &chat.name, &new_name, ContactId::SELF).await;
|
||||
msg.text = if chat.typ == Chattype::OutBroadcast {
|
||||
stock_str::msg_broadcast_name_changed(context, &chat.name, &new_name).await
|
||||
} else {
|
||||
stock_str::msg_grp_name(context, &chat.name, &new_name, ContactId::SELF).await
|
||||
};
|
||||
msg.param.set_cmd(SystemMessage::GroupNameChanged);
|
||||
if !chat.name.is_empty() {
|
||||
msg.param.set(Param::Arg, &chat.name);
|
||||
@@ -4405,7 +4387,11 @@ pub async fn set_chat_profile_image(
|
||||
if new_image.is_empty() {
|
||||
chat.param.remove(Param::ProfileImage);
|
||||
msg.param.remove(Param::Arg);
|
||||
msg.text = stock_str::msg_grp_img_deleted(context, ContactId::SELF).await;
|
||||
msg.text = if chat.typ == Chattype::OutBroadcast {
|
||||
stock_str::msg_broadcast_img_changed(context).await
|
||||
} else {
|
||||
stock_str::msg_grp_img_deleted(context, ContactId::SELF).await
|
||||
};
|
||||
} else {
|
||||
let mut image_blob = BlobObject::create_and_deduplicate(
|
||||
context,
|
||||
@@ -4415,7 +4401,11 @@ pub async fn set_chat_profile_image(
|
||||
image_blob.recode_to_avatar_size(context).await?;
|
||||
chat.param.set(Param::ProfileImage, image_blob.as_name());
|
||||
msg.param.set(Param::Arg, image_blob.as_name());
|
||||
msg.text = stock_str::msg_grp_img_changed(context, ContactId::SELF).await;
|
||||
msg.text = if chat.typ == Chattype::OutBroadcast {
|
||||
stock_str::msg_broadcast_img_changed(context).await
|
||||
} else {
|
||||
stock_str::msg_grp_img_changed(context, ContactId::SELF).await
|
||||
};
|
||||
}
|
||||
chat.update_param(context).await?;
|
||||
if chat.is_promoted() {
|
||||
@@ -4433,6 +4423,7 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId)
|
||||
}
|
||||
|
||||
/// Forwards multiple messages to a chat in another context.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub async fn forward_msgs_2ctx(
|
||||
ctx_src: &Context,
|
||||
msg_ids: &[MsgId],
|
||||
@@ -4563,6 +4554,7 @@ pub async fn save_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
|
||||
/// the copy contains a reference to the original message
|
||||
/// as well as to the original chat in case the original message gets deleted.
|
||||
/// Returns data needed to add a `SaveMessage` sync item.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub(crate) async fn save_copy_in_self_talk(
|
||||
context: &Context,
|
||||
src_msg_id: MsgId,
|
||||
@@ -4655,7 +4647,6 @@ pub async fn resend_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
|
||||
}
|
||||
msg_state => bail!("Unexpected message state {msg_state}"),
|
||||
}
|
||||
msg.timestamp_sort = create_smeared_timestamp(context);
|
||||
if create_send_msg_jobs(context, &mut msg).await?.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -4741,6 +4732,7 @@ pub(crate) async fn get_chat_id_by_grpid(
|
||||
///
|
||||
/// Optional `label` can be provided to ensure that message is added only once.
|
||||
/// If `important` is true, a notification will be sent.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub async fn add_device_msg_with_importance(
|
||||
context: &Context,
|
||||
label: Option<&str>,
|
||||
@@ -5050,18 +5042,18 @@ async fn set_contacts_by_fingerprints(
|
||||
matches!(chat.typ, Chattype::Group | Chattype::OutBroadcast),
|
||||
"{id} is not a group or broadcast",
|
||||
);
|
||||
let mut contacts = HashSet::new();
|
||||
let mut contacts = BTreeSet::new();
|
||||
for (fingerprint, addr) in fingerprint_addrs {
|
||||
let contact = Contact::add_or_lookup_ex(context, "", addr, fingerprint, Origin::Hidden)
|
||||
.await?
|
||||
.0;
|
||||
contacts.insert(contact);
|
||||
}
|
||||
let contacts_old = HashSet::<ContactId>::from_iter(get_chat_contacts(context, id).await?);
|
||||
let contacts_old = BTreeSet::<ContactId>::from_iter(get_chat_contacts(context, id).await?);
|
||||
if contacts == contacts_old {
|
||||
return Ok(());
|
||||
}
|
||||
context
|
||||
let broadcast_contacts_added = context
|
||||
.sql
|
||||
.transaction(move |transaction| {
|
||||
// For broadcast channels, we only add members,
|
||||
@@ -5078,12 +5070,31 @@ async fn set_contacts_by_fingerprints(
|
||||
let mut statement = transaction.prepare(
|
||||
"INSERT OR IGNORE INTO chats_contacts (chat_id, contact_id) VALUES (?, ?)",
|
||||
)?;
|
||||
let mut broadcast_contacts_added = Vec::new();
|
||||
for contact_id in &contacts {
|
||||
statement.execute((id, contact_id))?;
|
||||
if statement.execute((id, contact_id))? > 0 && chat.typ == Chattype::OutBroadcast {
|
||||
broadcast_contacts_added.push(*contact_id);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(broadcast_contacts_added)
|
||||
})
|
||||
.await?;
|
||||
let timestamp = smeared_time(context);
|
||||
for added_id in broadcast_contacts_added {
|
||||
let msg = stock_str::msg_add_member_local(context, added_id, ContactId::UNDEFINED).await;
|
||||
add_info_msg_with_cmd(
|
||||
context,
|
||||
id,
|
||||
&msg,
|
||||
SystemMessage::MemberAddedToGroup,
|
||||
Some(timestamp),
|
||||
timestamp,
|
||||
None,
|
||||
Some(ContactId::SELF),
|
||||
Some(added_id),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
context.emit_event(EventType::ChatModified(id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2641,7 +2641,7 @@ async fn test_resend_own_message() -> Result<()> {
|
||||
);
|
||||
let msg_from = Contact::get_by_id(&fiona, msg.get_from_id()).await?;
|
||||
assert_eq!(msg_from.get_addr(), "alice@example.org");
|
||||
assert!(sent1_ts_sent < msg.timestamp_sent);
|
||||
assert!(sent1_ts_sent == msg.timestamp_sent);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2731,27 +2731,24 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
|
||||
join_securejoin(charlie, &qr).await.unwrap();
|
||||
|
||||
let request = charlie.pop_sent_msg().await;
|
||||
assert_eq!(request.recipients, "alice@example.org charlie@example.net");
|
||||
assert_eq!(request.recipients, "alice@example.org");
|
||||
|
||||
alice.recv_msg_trash(&request).await;
|
||||
}
|
||||
|
||||
tcm.section("Alice sends auth-required");
|
||||
tcm.section("Alice sends vc-pubkey");
|
||||
{
|
||||
let auth_required = alice.pop_sent_msg().await;
|
||||
assert_eq!(
|
||||
auth_required.recipients,
|
||||
"charlie@example.net alice@example.org"
|
||||
);
|
||||
let parsed = charlie.parse_msg(&auth_required).await;
|
||||
assert!(parsed.get_header(HeaderDef::AutocryptGossip).is_some());
|
||||
assert!(parsed.decoded_data_contains("charlie@example.net"));
|
||||
let vc_pubkey = alice.pop_sent_msg().await;
|
||||
assert_eq!(vc_pubkey.recipients, "charlie@example.net");
|
||||
let parsed = charlie.parse_msg(&vc_pubkey).await;
|
||||
assert!(parsed.get_header(HeaderDef::AutocryptGossip).is_none());
|
||||
assert_eq!(parsed.decoded_data_contains("charlie@example.net"), false);
|
||||
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
|
||||
|
||||
let parsed_by_bob = bob.parse_msg(&auth_required).await;
|
||||
let parsed_by_bob = bob.parse_msg(&vc_pubkey).await;
|
||||
assert!(parsed_by_bob.decrypting_failed);
|
||||
|
||||
charlie.recv_msg_trash(&auth_required).await;
|
||||
charlie.recv_msg_trash(&vc_pubkey).await;
|
||||
}
|
||||
|
||||
tcm.section("Charlie sends request-with-auth");
|
||||
@@ -2992,27 +2989,49 @@ async fn test_broadcast_recipients_sync1() -> Result<()> {
|
||||
alice1.recv_msg_trash(&request).await;
|
||||
alice2.recv_msg_trash(&request).await;
|
||||
|
||||
let auth_required = alice1.pop_sent_msg().await;
|
||||
charlie.recv_msg_trash(&auth_required).await;
|
||||
alice2.recv_msg_trash(&auth_required).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_member_added = alice2.recv_msg(&member_added).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_chatlist = Chatlist::try_load(alice2, 0, Some("Channel"), None).await?;
|
||||
assert_eq!(a2_chatlist.get_msg_id(0)?.unwrap(), a2_member_added.id);
|
||||
|
||||
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_member_added.chat_id).await?;
|
||||
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);
|
||||
@@ -3118,7 +3137,7 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
|
||||
assert_eq!(rcvd.get_info_type(), SystemMessage::GroupNameChanged);
|
||||
assert_eq!(
|
||||
rcvd.text,
|
||||
r#"Group name changed from "My Channel" to "New Channel name" by Alice."#
|
||||
r#"Channel name changed from "My Channel" to "New Channel name"."#
|
||||
);
|
||||
let bob_chat = Chat::load_from_db(bob, bob_chat.id).await?;
|
||||
assert_eq!(bob_chat.name, "New Channel name");
|
||||
@@ -3135,7 +3154,7 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
|
||||
let rcvd = bob.recv_msg(&sent).await;
|
||||
assert!(rcvd.get_override_sender_name().is_none());
|
||||
assert_eq!(rcvd.get_info_type(), SystemMessage::GroupImageChanged);
|
||||
assert_eq!(rcvd.text, "Group image changed by Alice.");
|
||||
assert_eq!(rcvd.text, "Channel image changed.");
|
||||
assert_eq!(rcvd.chat_id, bob_chat.id);
|
||||
|
||||
let bob_chat = Chat::load_from_db(bob, bob_chat.id).await?;
|
||||
@@ -3158,29 +3177,59 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_chat_description_basic() {
|
||||
test_chat_description("", false).await.unwrap()
|
||||
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)
|
||||
.await
|
||||
.unwrap()
|
||||
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).await.unwrap()
|
||||
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)
|
||||
.await
|
||||
.unwrap()
|
||||
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) -> Result<()> {
|
||||
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;
|
||||
@@ -3190,12 +3239,29 @@ async fn test_chat_description(initial_description: &str, join_via_qr: bool) ->
|
||||
alice2.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
|
||||
tcm.section("Create a group chat, and add Bob");
|
||||
let alice_chat_id = create_group(alice, "My Group").await?;
|
||||
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;
|
||||
}
|
||||
}
|
||||
sync(alice, alice2).await;
|
||||
|
||||
let alice2_chat_id = get_chat_id_by_grpid(
|
||||
alice2,
|
||||
@@ -3223,7 +3289,7 @@ async fn test_chat_description(initial_description: &str, join_via_qr: bool) ->
|
||||
initial_description
|
||||
);
|
||||
|
||||
for description in ["This is a cool group", "", "ä ẟ 😂"] {
|
||||
for description in ["This is a cool chat", "", "ä ẟ 😂"] {
|
||||
tcm.section(&format!(
|
||||
"Alice sets the chat description to '{description}'"
|
||||
));
|
||||
@@ -3235,6 +3301,11 @@ async fn test_chat_description(initial_description: &str, join_via_qr: bool) ->
|
||||
);
|
||||
|
||||
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.");
|
||||
@@ -3269,6 +3340,36 @@ async fn test_chat_description(initial_description: &str, join_via_qr: bool) ->
|
||||
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)]
|
||||
@@ -3299,14 +3400,17 @@ async fn test_broadcast_joining_golden() -> Result<()> {
|
||||
.await;
|
||||
|
||||
let alice_bob_contact = alice.add_or_lookup_contact_no_key(bob).await;
|
||||
let private_chat = ChatIdBlocked::lookup_by_contact(alice, alice_bob_contact.id)
|
||||
.await?
|
||||
.unwrap();
|
||||
// The 1:1 chat with Bob should not be visible to the user:
|
||||
assert_eq!(private_chat.blocked, Blocked::Yes);
|
||||
assert!(
|
||||
ChatIdBlocked::lookup_by_contact(alice, alice_bob_contact.id)
|
||||
.await?
|
||||
.is_none()
|
||||
);
|
||||
let private_chat_id =
|
||||
ChatId::create_for_contact_with_blocked(alice, alice_bob_contact.id, Blocked::Not).await?;
|
||||
alice
|
||||
.golden_test_chat(
|
||||
private_chat.id,
|
||||
private_chat_id,
|
||||
"test_broadcast_joining_golden_private_chat",
|
||||
)
|
||||
.await;
|
||||
@@ -3583,16 +3687,13 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
|
||||
join_securejoin(bob0, &qr).await.unwrap();
|
||||
|
||||
let request = bob0.pop_sent_msg().await;
|
||||
assert_eq!(request.recipients, "alice@example.org bob@example.net");
|
||||
assert_eq!(request.recipients, "alice@example.org");
|
||||
|
||||
alice.recv_msg_trash(&request).await;
|
||||
let auth_required = alice.pop_sent_msg().await;
|
||||
assert_eq!(
|
||||
auth_required.recipients,
|
||||
"bob@example.net alice@example.org"
|
||||
);
|
||||
let vc_pubkey = alice.pop_sent_msg().await;
|
||||
assert_eq!(vc_pubkey.recipients, "bob@example.net");
|
||||
|
||||
bob0.recv_msg_trash(&auth_required).await;
|
||||
bob0.recv_msg_trash(&vc_pubkey).await;
|
||||
let request_with_auth = bob0.pop_sent_msg().await;
|
||||
assert_eq!(
|
||||
request_with_auth.recipients,
|
||||
@@ -3608,7 +3709,7 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
|
||||
assert_eq!(rcvd.param.get_cmd(), SystemMessage::MemberAddedToGroup);
|
||||
|
||||
tcm.section("Bob's second device also receives these messages");
|
||||
bob1.recv_msg_trash(&auth_required).await;
|
||||
bob1.recv_msg_trash(&vc_pubkey).await;
|
||||
bob1.recv_msg_trash(&request_with_auth).await;
|
||||
bob1.recv_msg(&member_added).await;
|
||||
|
||||
@@ -3638,7 +3739,7 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
|
||||
|
||||
let leave_msg = bob0.pop_sent_msg().await;
|
||||
let parsed = MimeMessage::from_bytes(bob1, leave_msg.payload().as_bytes()).await?;
|
||||
assert_eq!(parsed.parts[0].msg, "I left the group.");
|
||||
assert_eq!(parsed.parts[0].msg, "bob@example.net left the group.");
|
||||
|
||||
let rcvd = bob1.recv_msg(&leave_msg).await;
|
||||
|
||||
@@ -3705,7 +3806,7 @@ async fn test_only_broadcast_owner_can_send_1() -> Result<()> {
|
||||
"Bob receives an answer, but shows it in 1:1 chat because of a fingerprint mismatch",
|
||||
);
|
||||
let rcvd = bob.recv_msg(&member_added).await;
|
||||
assert_eq!(rcvd.text, "I added member bob@example.net.");
|
||||
assert_eq!(rcvd.text, "Member bob@example.net was added.");
|
||||
|
||||
let bob_alice_chat_id = bob.get_chat(alice).await.id;
|
||||
assert_eq!(rcvd.chat_id, bob_alice_chat_id);
|
||||
@@ -3807,7 +3908,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?;
|
||||
@@ -3821,7 +3922,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();
|
||||
@@ -4623,6 +4724,9 @@ async fn test_sync_broadcast_and_send_message() -> Result<()> {
|
||||
vec![a2b_contact_id]
|
||||
);
|
||||
|
||||
// alice2's smeared clock may be behind alice's one, so "hi" from alice2 may appear before "You
|
||||
// joined the channel." for bob.
|
||||
SystemTime::shift(Duration::from_secs(1));
|
||||
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;
|
||||
@@ -4687,7 +4791,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)
|
||||
@@ -4757,6 +4861,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -590,11 +590,14 @@ 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:#}")).await
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
progress!(ctx, 850);
|
||||
@@ -609,7 +612,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);
|
||||
|
||||
@@ -673,6 +673,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
|
||||
}
|
||||
@@ -1071,6 +1072,7 @@ VALUES (?, ?, ?, ?, ?, ?)
|
||||
/// 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;
|
||||
|
||||
@@ -1342,7 +1344,7 @@ WHERE addr=?
|
||||
let fingerprint_other = fingerprint_other.to_string();
|
||||
|
||||
let stock_message = if contact.public_key(context).await?.is_some() {
|
||||
stock_str::messages_e2e_encrypted(context).await
|
||||
stock_str::messages_are_e2ee(context).await
|
||||
} else {
|
||||
stock_str::encr_none(context).await
|
||||
};
|
||||
@@ -1909,6 +1911,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,
|
||||
@@ -2000,6 +2003,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}");
|
||||
}
|
||||
@@ -2041,6 +2045,7 @@ impl RecentlySeenLoop {
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn run(context: Context, interrupt: Receiver<RecentlySeenInterrupt>) {
|
||||
type MyHeapElem = (Reverse<i64>, ContactId);
|
||||
|
||||
|
||||
@@ -1145,8 +1145,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);
|
||||
|
||||
@@ -239,6 +239,9 @@ pub struct InnerContext {
|
||||
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<()>,
|
||||
|
||||
pub(crate) translated_stockstrings: StockStrings,
|
||||
pub(crate) events: Events,
|
||||
|
||||
@@ -342,6 +345,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();
|
||||
|
||||
@@ -477,6 +481,7 @@ impl Context {
|
||||
generating_key_mutex: Mutex::new(()),
|
||||
oauth2_mutex: Mutex::new(()),
|
||||
wrong_pw_warning_mutex: Mutex::new(()),
|
||||
housekeeping_mutex: Mutex::new(()),
|
||||
translated_stockstrings: stockstrings,
|
||||
events,
|
||||
scheduler: SchedulerState::new(),
|
||||
|
||||
@@ -1,34 +1,28 @@
|
||||
//! 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 ::pgp::composed::Message;
|
||||
use anyhow::Result;
|
||||
use mailparse::ParsedMail;
|
||||
|
||||
use crate::key::{Fingerprint, SignedPublicKey, SignedSecretKey};
|
||||
use crate::key::{Fingerprint, SignedPublicKey};
|
||||
use crate::pgp;
|
||||
|
||||
/// Tries to decrypt a message, but only if it is structured as an Autocrypt message.
|
||||
///
|
||||
/// If successful and the message was encrypted,
|
||||
/// returns the decrypted and decompressed message.
|
||||
pub fn try_decrypt<'a>(
|
||||
mail: &'a ParsedMail<'a>,
|
||||
private_keyring: &'a [SignedSecretKey],
|
||||
shared_secrets: &[String],
|
||||
) -> Result<Option<::pgp::composed::Message<'static>>> {
|
||||
pub fn get_encrypted_pgp_message<'a>(mail: &'a ParsedMail<'a>) -> Result<Option<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)?;
|
||||
|
||||
let cursor = Cursor::new(data);
|
||||
let (msg, _headers) = Message::from_armor(cursor)?;
|
||||
Ok(Some(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))
|
||||
|
||||
@@ -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()
|
||||
@@ -280,6 +281,7 @@ fn dehtml_endtag_cb(event: &BytesEnd, dehtml: &mut Dehtml) {
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn dehtml_starttag_cb<B: std::io::BufRead>(
|
||||
event: &BytesStart,
|
||||
dehtml: &mut Dehtml,
|
||||
@@ -356,6 +358,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 +367,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>,
|
||||
|
||||
@@ -41,6 +41,14 @@ impl PostMsgMetadata {
|
||||
.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),
|
||||
|
||||
@@ -70,8 +70,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);
|
||||
|
||||
@@ -593,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;
|
||||
@@ -650,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();
|
||||
|
||||
|
||||
@@ -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`].
|
||||
|
||||
@@ -171,14 +171,19 @@ pub enum EventType {
|
||||
msg_id: MsgId,
|
||||
},
|
||||
|
||||
/// A single message is read by the receiver. State changed from DC_STATE_OUT_DELIVERED to
|
||||
/// DC_STATE_OUT_MDN_RCVD, see dc_msg_get_state().
|
||||
/// A single message is read by a receiver.
|
||||
MsgRead {
|
||||
/// ID of the chat which the message belongs to.
|
||||
chat_id: ChatId,
|
||||
|
||||
/// ID of the message that was read.
|
||||
msg_id: MsgId,
|
||||
|
||||
/// Read for the first time (e.g. by just one group member
|
||||
/// / channel subscriber).
|
||||
/// State changed from DC_STATE_OUT_DELIVERED to
|
||||
/// DC_STATE_OUT_MDN_RCVD, see dc_msg_get_state().
|
||||
first_time: bool,
|
||||
},
|
||||
|
||||
/// A single message was deleted.
|
||||
|
||||
@@ -86,6 +86,7 @@ impl HtmlMsgParser {
|
||||
/// Function takes a raw mime-message string,
|
||||
/// searches for the main-text part
|
||||
/// and returns that as parser.html
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub async fn from_bytes<'a>(
|
||||
context: &Context,
|
||||
rawmime: &'a [u8],
|
||||
@@ -119,6 +120,7 @@ impl HtmlMsgParser {
|
||||
/// Usually, there is at most one plain-text and one HTML-text part,
|
||||
/// multiple plain-text parts might be used for mailinglist-footers,
|
||||
/// therefore we use the first one.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn collect_texts_recursive<'a>(
|
||||
&'a mut self,
|
||||
context: &'a Context,
|
||||
|
||||
43
src/imap.rs
43
src/imap.rs
@@ -18,7 +18,6 @@ use async_channel::{self, Receiver, Sender};
|
||||
use async_imap::types::{Fetch, Flag, Name, NameAttribute, UnsolicitedResponse};
|
||||
use futures::{FutureExt as _, TryStreamExt};
|
||||
use futures_lite::FutureExt;
|
||||
use num_traits::FromPrimitive;
|
||||
use ratelimit::Ratelimit;
|
||||
use url::Url;
|
||||
|
||||
@@ -28,7 +27,7 @@ use crate::calls::{
|
||||
use crate::chat::{self, ChatId, ChatIdBlocked, add_device_msg};
|
||||
use crate::chatlist_events;
|
||||
use crate::config::Config;
|
||||
use crate::constants::{self, Blocked, DC_VERSION_STR, ShowEmails};
|
||||
use crate::constants::{self, Blocked, DC_VERSION_STR};
|
||||
use crate::contact::ContactId;
|
||||
use crate::context::Context;
|
||||
use crate::events::EventType;
|
||||
@@ -208,6 +207,7 @@ impl<T: Iterator<Item = (i64, u32, String)>> Iterator for UidGrouper<T> {
|
||||
// Tuple of folder, row IDs, and UID range as a string.
|
||||
type Item = (String, Vec<i64>, String);
|
||||
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let (_, _, folder) = self.inner.peek().cloned()?;
|
||||
|
||||
@@ -356,10 +356,10 @@ impl Imap {
|
||||
context,
|
||||
self.proxy_config.clone(),
|
||||
self.strict_tls,
|
||||
connection_candidate,
|
||||
&connection_candidate,
|
||||
)
|
||||
.await
|
||||
.context("IMAP failed to connect")
|
||||
.with_context(|| format!("IMAP failed to connect to {connection_candidate}"))
|
||||
{
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
@@ -543,6 +543,7 @@ impl Imap {
|
||||
/// Fetches new messages.
|
||||
///
|
||||
/// Returns true if at least one message was fetched.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub(crate) async fn fetch_new_messages(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
@@ -583,6 +584,7 @@ impl Imap {
|
||||
}
|
||||
|
||||
/// Returns number of messages processed and whether the function should be called again.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn fetch_new_msg_batch(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
@@ -1265,6 +1267,7 @@ impl Session {
|
||||
///
|
||||
/// If the message is incorrect or there is a failure to write a message to the database,
|
||||
/// it is skipped and the error is logged.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub(crate) async fn fetch_many_msgs(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
@@ -1429,6 +1432,7 @@ impl Session {
|
||||
/// We get [`/shared/comment`](https://www.rfc-editor.org/rfc/rfc5464#section-6.2.1)
|
||||
/// and [`/shared/admin`](https://www.rfc-editor.org/rfc/rfc5464#section-6.2.2)
|
||||
/// metadata.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub(crate) async fn update_metadata(&mut self, context: &Context) -> Result<()> {
|
||||
let mut lock = context.metadata.write().await;
|
||||
|
||||
@@ -2126,16 +2130,11 @@ pub(crate) async fn prefetch_should_download(
|
||||
false
|
||||
};
|
||||
|
||||
// Autocrypt Setup Message should be shown even if it is from non-chat client.
|
||||
let is_autocrypt_setup_message = headers
|
||||
.get_header_value(HeaderDef::AutocryptSetupMessage)
|
||||
.is_some();
|
||||
|
||||
let from = match mimeparser::get_from(headers) {
|
||||
Some(f) => f,
|
||||
None => return Ok(false),
|
||||
};
|
||||
let (_from_id, blocked_contact, origin) =
|
||||
let (_from_id, blocked_contact, _origin) =
|
||||
match from_field_to_contact_id(context, &from, None, true, true).await? {
|
||||
Some(res) => res,
|
||||
None => return Ok(false),
|
||||
@@ -2148,28 +2147,7 @@ pub(crate) async fn prefetch_should_download(
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let is_chat_message = headers.get_header_value(HeaderDef::ChatVersion).is_some();
|
||||
let accepted_contact = origin.is_known();
|
||||
let is_reply_to_chat_message = get_prefetch_parent_message(context, headers)
|
||||
.await?
|
||||
.is_some_and(|parent| match parent.is_dc_message {
|
||||
MessengerMessage::No => false,
|
||||
MessengerMessage::Yes | MessengerMessage::Reply => true,
|
||||
});
|
||||
|
||||
let show_emails =
|
||||
ShowEmails::from_i32(context.get_config_int(Config::ShowEmails).await?).unwrap_or_default();
|
||||
|
||||
let show = is_autocrypt_setup_message
|
||||
|| match show_emails {
|
||||
ShowEmails::Off => is_chat_message || is_reply_to_chat_message,
|
||||
ShowEmails::AcceptedContacts => {
|
||||
is_chat_message || is_reply_to_chat_message || accepted_contact
|
||||
}
|
||||
ShowEmails::All => true,
|
||||
};
|
||||
|
||||
let should_download = (show && !blocked_contact) || maybe_ndn;
|
||||
let should_download = (!blocked_contact) || maybe_ndn;
|
||||
Ok(should_download)
|
||||
}
|
||||
|
||||
@@ -2364,6 +2342,7 @@ async fn should_ignore_folder(
|
||||
/// Builds a list of sequence/uid sets. The returned sets have each no more than around 1000
|
||||
/// characters because according to <https://tools.ietf.org/html/rfc2683#section-3.2.1.5>
|
||||
/// command lines should not be much more than 1000 chars (servers should allow at least 8000 chars)
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn build_sequence_sets(uids: &[u32]) -> Result<Vec<(Vec<u32>, String)>> {
|
||||
// first, try to find consecutive ranges:
|
||||
let mut ranges: Vec<UidRange> = vec![];
|
||||
|
||||
@@ -150,7 +150,7 @@ impl Client {
|
||||
Err(err) => {
|
||||
warn!(
|
||||
context,
|
||||
"Failed to connect to {host} ({resolved_addr}): {err:#}."
|
||||
"IMAP failed to connect to {host} ({resolved_addr}): {err:#}."
|
||||
);
|
||||
Err(err)
|
||||
}
|
||||
@@ -161,7 +161,7 @@ impl Client {
|
||||
context: &Context,
|
||||
proxy_config: Option<ProxyConfig>,
|
||||
strict_tls: bool,
|
||||
candidate: ConnectionCandidate,
|
||||
candidate: &ConnectionCandidate,
|
||||
) -> Result<Self> {
|
||||
let host = &candidate.host;
|
||||
let port = candidate.port;
|
||||
|
||||
@@ -21,10 +21,8 @@ const PREFETCH_FLAGS: &str = "(UID INTERNALDATE RFC822.SIZE BODY.PEEK[HEADER.FIE
|
||||
DATE \
|
||||
X-MICROSOFT-ORIGINAL-MESSAGE-ID \
|
||||
FROM \
|
||||
IN-REPLY-TO REFERENCES \
|
||||
CHAT-VERSION \
|
||||
CHAT-IS-POST-MESSAGE \
|
||||
AUTO-SUBMITTED \
|
||||
AUTOCRYPT-SETUP-MESSAGE\
|
||||
)])";
|
||||
|
||||
@@ -127,6 +125,7 @@ impl Session {
|
||||
|
||||
/// Prefetch `n_uids` messages starting from `uid_next`. Returns a list of fetch results in the
|
||||
/// order of ascending delivery time to the server (INTERNALDATE).
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub(crate) async fn prefetch(
|
||||
&mut self,
|
||||
uid_next: u32,
|
||||
|
||||
204
src/imex.rs
204
src/imex.rs
@@ -19,9 +19,8 @@ use crate::config::Config;
|
||||
use crate::context::Context;
|
||||
use crate::e2ee;
|
||||
use crate::events::EventType;
|
||||
use crate::key::{self, DcKey, SignedPublicKey, SignedSecretKey};
|
||||
use crate::key::{self, DcKey, SignedSecretKey};
|
||||
use crate::log::{LogExt, warn};
|
||||
use crate::pgp;
|
||||
use crate::qr::DCBACKUP_VERSION;
|
||||
use crate::sql;
|
||||
use crate::tools::{
|
||||
@@ -103,7 +102,8 @@ pub async fn imex(
|
||||
|
||||
if let Err(err) = res.as_ref() {
|
||||
// We are using Anyhow's .context() and to show the inner error, too, we need the {:#}:
|
||||
error!(context, "IMEX failed to complete: {:#}", err);
|
||||
error!(context, "{:#}", err);
|
||||
warn!(context, "IMEX failed to complete: {:#}", err);
|
||||
context.emit_event(EventType::ImexProgress(0));
|
||||
} else {
|
||||
info!(context, "IMEX successfully completed");
|
||||
@@ -141,19 +141,13 @@ pub async fn has_backup(_context: &Context, dir_name: &Path) -> Result<String> {
|
||||
}
|
||||
|
||||
async fn set_self_key(context: &Context, armored: &str) -> Result<()> {
|
||||
let private_key = SignedSecretKey::from_asc(armored)?;
|
||||
let public_key = private_key.to_public_key();
|
||||
|
||||
let keypair = pgp::KeyPair {
|
||||
public: public_key,
|
||||
secret: private_key,
|
||||
};
|
||||
key::store_self_keypair(context, &keypair).await?;
|
||||
let secret_key = SignedSecretKey::from_asc(armored)?;
|
||||
key::store_self_keypair(context, &secret_key).await?;
|
||||
|
||||
info!(
|
||||
context,
|
||||
"stored self key: {:?}",
|
||||
keypair.secret.public_key().legacy_key_id()
|
||||
"Stored self key: {:?}.",
|
||||
secret_key.public_key().fingerprint()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -209,15 +203,6 @@ async fn import_backup(
|
||||
backup_to_import: &Path,
|
||||
passphrase: String,
|
||||
) -> Result<()> {
|
||||
ensure!(
|
||||
!context.is_configured().await?,
|
||||
"Cannot import backups to accounts in use."
|
||||
);
|
||||
ensure!(
|
||||
!context.scheduler.is_running().await,
|
||||
"cannot import backup, IO is running"
|
||||
);
|
||||
|
||||
let backup_file = File::open(backup_to_import).await?;
|
||||
let file_size = backup_file.metadata().await?.len();
|
||||
info!(
|
||||
@@ -251,6 +236,15 @@ pub(crate) async fn import_backup_stream<R: tokio::io::AsyncRead + Unpin>(
|
||||
file_size: u64,
|
||||
passphrase: String,
|
||||
) -> Result<()> {
|
||||
ensure!(
|
||||
!context.is_configured().await?,
|
||||
"Cannot import backups to accounts in use"
|
||||
);
|
||||
ensure!(
|
||||
!context.scheduler.is_running().await,
|
||||
"Cannot import backup, IO is running"
|
||||
);
|
||||
|
||||
import_backup_stream_inner(context, backup_file, file_size, passphrase)
|
||||
.await
|
||||
.0
|
||||
@@ -293,6 +287,7 @@ impl<R> AsyncRead for ProgressReader<R>
|
||||
where
|
||||
R: AsyncRead,
|
||||
{
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
@@ -316,6 +311,9 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
// This function returns a tuple (Result<()>,) rather than Result<()>
|
||||
// so that we don't accidentally early-return with `?`
|
||||
// and forget to cleanup.
|
||||
async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
|
||||
context: &Context,
|
||||
backup_file: R,
|
||||
@@ -362,11 +360,6 @@ async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
|
||||
}
|
||||
}
|
||||
};
|
||||
if res.is_err() {
|
||||
for blob in blobs {
|
||||
fs::remove_file(&blob).await.log_err(context).ok();
|
||||
}
|
||||
}
|
||||
|
||||
let unpacked_database = context.get_blobdir().join(DBFILE_BACKUP_NAME);
|
||||
if res.is_ok() {
|
||||
@@ -379,17 +372,6 @@ async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
|
||||
if res.is_ok() {
|
||||
res = check_backup_version(context).await;
|
||||
}
|
||||
if res.is_ok() {
|
||||
// All recent backups have `bcc_self` set to "1" before export.
|
||||
//
|
||||
// Setting `bcc_self` to "1" on export was introduced on 2024-12-17
|
||||
// in commit 21664125d798021be75f47d5b0d5006d338b4531
|
||||
//
|
||||
// We additionally try to set `bcc_self` to "1" after import here
|
||||
// for compatibility with older backups,
|
||||
// but eventually this code can be removed.
|
||||
res = context.set_config(Config::BccSelf, Some("1")).await;
|
||||
}
|
||||
fs::remove_file(unpacked_database)
|
||||
.await
|
||||
.context("cannot remove unpacked database")
|
||||
@@ -400,6 +382,22 @@ async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
|
||||
res = context.sql.run_migrations(context).await;
|
||||
context.emit_event(EventType::AccountsItemChanged);
|
||||
}
|
||||
if res.is_err() {
|
||||
context.sql.close().await;
|
||||
fs::remove_file(context.sql.dbfile.as_path())
|
||||
.await
|
||||
.log_err(context)
|
||||
.ok();
|
||||
for blob in blobs {
|
||||
fs::remove_file(&blob).await.log_err(context).ok();
|
||||
}
|
||||
context
|
||||
.sql
|
||||
.open(context, "".to_string())
|
||||
.await
|
||||
.log_err(context)
|
||||
.ok();
|
||||
}
|
||||
if res.is_ok() {
|
||||
delete_and_reset_all_device_msgs(context)
|
||||
.await
|
||||
@@ -449,6 +447,7 @@ fn get_next_backup_path(
|
||||
/// Exports the database to a separate file with the given passphrase.
|
||||
///
|
||||
/// Set passphrase to empty string to export the database unencrypted.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn export_backup(context: &Context, dir: &Path, passphrase: String) -> Result<()> {
|
||||
// get a fine backup file name (the name includes the date so that multiple backup instances are possible)
|
||||
let now = time();
|
||||
@@ -522,6 +521,7 @@ impl<W> AsyncWrite for ProgressWriter<W>
|
||||
where
|
||||
W: AsyncWrite,
|
||||
{
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
@@ -601,6 +601,7 @@ async fn import_secret_key(context: &Context, path: &Path) -> Result<()> {
|
||||
/// containing secret keys are imported and the last successfully
|
||||
/// imported which does not contain "legacy" in its filename
|
||||
/// is set as the default.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn import_self_keys(context: &Context, path: &Path) -> Result<()> {
|
||||
let attr = tokio::fs::metadata(path).await?;
|
||||
|
||||
@@ -654,44 +655,43 @@ async fn import_self_keys(context: &Context, path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn export_self_keys(context: &Context, dir: &Path) -> Result<()> {
|
||||
let mut export_errors = 0;
|
||||
|
||||
let keys = context
|
||||
.sql
|
||||
.query_map_vec(
|
||||
"SELECT id, public_key, private_key, id=(SELECT value FROM config WHERE keyname='key_id') FROM keypairs;",
|
||||
"SELECT id, private_key, id=(SELECT value FROM config WHERE keyname='key_id') FROM keypairs;",
|
||||
(),
|
||||
|row| {
|
||||
let id = row.get(0)?;
|
||||
let public_key_blob: Vec<u8> = row.get(1)?;
|
||||
let public_key = SignedPublicKey::from_slice(&public_key_blob);
|
||||
let private_key_blob: Vec<u8> = row.get(2)?;
|
||||
let private_key_blob: Vec<u8> = row.get(1)?;
|
||||
let private_key = SignedSecretKey::from_slice(&private_key_blob);
|
||||
let is_default: i32 = row.get(3)?;
|
||||
let is_default: i32 = row.get(2)?;
|
||||
|
||||
Ok((id, public_key, private_key, is_default))
|
||||
Ok((id, private_key, is_default))
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let self_addr = context.get_primary_self_addr().await?;
|
||||
for (id, public_key, private_key, is_default) in keys {
|
||||
for (id, private_key, is_default) in keys {
|
||||
let id = Some(id).filter(|_| is_default == 0);
|
||||
|
||||
if let Ok(key) = public_key {
|
||||
if let Err(err) = export_key_to_asc_file(context, dir, &self_addr, id, &key).await {
|
||||
error!(context, "Failed to export public key: {:#}.", err);
|
||||
export_errors += 1;
|
||||
}
|
||||
} else {
|
||||
let Ok(private_key) = private_key else {
|
||||
export_errors += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Err(err) = export_key_to_asc_file(context, dir, &self_addr, id, &private_key).await {
|
||||
error!(context, "Failed to export private key: {:#}.", err);
|
||||
export_errors += 1;
|
||||
}
|
||||
if let Ok(key) = private_key {
|
||||
if let Err(err) = export_key_to_asc_file(context, dir, &self_addr, id, &key).await {
|
||||
error!(context, "Failed to export private key: {:#}.", err);
|
||||
export_errors += 1;
|
||||
}
|
||||
} else {
|
||||
|
||||
let public_key = private_key.to_public_key();
|
||||
|
||||
if let Err(err) = export_key_to_asc_file(context, dir, &self_addr, id, &public_key).await {
|
||||
error!(context, "Failed to export public key: {:#}.", err);
|
||||
export_errors += 1;
|
||||
}
|
||||
}
|
||||
@@ -721,12 +721,7 @@ where
|
||||
format!("{kind}-key-{addr}-{id}-{fp}.asc")
|
||||
};
|
||||
let path = dir.join(&file_name);
|
||||
info!(
|
||||
context,
|
||||
"Exporting key {:?} to {}.",
|
||||
key.key_id(),
|
||||
path.display()
|
||||
);
|
||||
info!(context, "Exporting key to {}.", path.display());
|
||||
|
||||
// Delete the file if it already exists.
|
||||
delete_file(context, &path).await.ok();
|
||||
@@ -800,7 +795,7 @@ async fn check_backup_version(context: &Context) -> Result<()> {
|
||||
let version = (context.sql.get_raw_config_int("backup_version").await?).unwrap_or(2);
|
||||
ensure!(
|
||||
version <= DCBACKUP_VERSION,
|
||||
"Backup too new, please update Delta Chat"
|
||||
"This profile is from a newer version of Delta Chat. Please update Delta Chat and try again (profile version is v{version}, the latest supported is v{DCBACKUP_VERSION})"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -813,12 +808,12 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::test_utils::{TestContext, alice_keypair};
|
||||
use crate::test_utils::{TestContext, TestContextManager, alice_keypair};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_export_public_key_to_asc_file() {
|
||||
let context = TestContext::new().await;
|
||||
let key = alice_keypair().public;
|
||||
let key = alice_keypair().to_public_key();
|
||||
let blobdir = Path::new("$BLOBDIR");
|
||||
let filename = export_key_to_asc_file(&context.ctx, blobdir, "a@b", None, &key)
|
||||
.await
|
||||
@@ -835,7 +830,7 @@ mod tests {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_import_private_key_exported_to_asc_file() {
|
||||
let context = TestContext::new().await;
|
||||
let key = alice_keypair().secret;
|
||||
let key = alice_keypair();
|
||||
let blobdir = Path::new("$BLOBDIR");
|
||||
let filename = export_key_to_asc_file(&context.ctx, blobdir, "a@b", None, &key)
|
||||
.await
|
||||
@@ -1030,6 +1025,81 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that [`crate::qr::DCBACKUP_VERSION`] is checked correctly.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_import_backup_fails_because_of_dcbackup_version() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let context1 = tcm.alice().await;
|
||||
let context2 = tcm.unconfigured().await;
|
||||
|
||||
assert!(context1.is_configured().await?);
|
||||
assert!(!context2.is_configured().await?);
|
||||
|
||||
let backup_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
tcm.section("export from context1");
|
||||
assert!(
|
||||
imex(&context1, ImexMode::ExportBackup, backup_dir.path(), None)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
let _event = context1
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::ImexProgress(1000)))
|
||||
.await;
|
||||
let backup = has_backup(&context2, backup_dir.path()).await?;
|
||||
let modified_backup = backup_dir.path().join("modified_backup.tar");
|
||||
|
||||
tcm.section("Change backup_version to be higher than DCBACKUP_VERSION");
|
||||
{
|
||||
let unpack_dir = tempfile::tempdir().unwrap();
|
||||
let mut ar = Archive::new(File::open(&backup).await?);
|
||||
ar.unpack(&unpack_dir).await?;
|
||||
|
||||
let sql = sql::Sql::new(unpack_dir.path().join(DBFILE_BACKUP_NAME));
|
||||
sql.open(&context2, "".to_string()).await?;
|
||||
assert_eq!(
|
||||
sql.get_raw_config_int("backup_version").await?.unwrap(),
|
||||
DCBACKUP_VERSION
|
||||
);
|
||||
sql.set_raw_config_int("backup_version", DCBACKUP_VERSION + 1)
|
||||
.await?;
|
||||
sql.close().await;
|
||||
|
||||
let modified_backup_file = File::create(&modified_backup).await?;
|
||||
let mut builder = tokio_tar::Builder::new(modified_backup_file);
|
||||
builder.append_dir_all("", unpack_dir.path()).await?;
|
||||
builder.finish().await?;
|
||||
}
|
||||
|
||||
tcm.section("import to context2");
|
||||
let err = imex(&context2, ImexMode::ImportBackup, &modified_backup, None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().starts_with("This profile is from a newer version of Delta Chat. Please update Delta Chat and try again"));
|
||||
|
||||
// Some UIs show the error from the event to the user.
|
||||
// Therefore, it must also be a user-facing string, rather than some technical info:
|
||||
let err_event = context2
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::Error(_)))
|
||||
.await;
|
||||
let EventType::Error(err_msg) = err_event else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(err_msg.starts_with("This profile is from a newer version of Delta Chat. Please update Delta Chat and try again"));
|
||||
|
||||
context2
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::ImexProgress(0)))
|
||||
.await;
|
||||
|
||||
assert!(!context2.is_configured().await?);
|
||||
assert_eq!(context2.get_config(Config::ConfiguredAddr).await?, None);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// This is a regression test for
|
||||
/// https://github.com/deltachat/deltachat-android/issues/2263
|
||||
/// where the config cache wasn't reset properly after a backup.
|
||||
|
||||
@@ -129,6 +129,7 @@ pub async fn render_setup_file(context: &Context, passphrase: &str) -> Result<St
|
||||
}
|
||||
|
||||
/// Creates a new setup code for Autocrypt Setup Message.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn create_setup_code(_context: &Context) -> String {
|
||||
let mut random_val: u16;
|
||||
let mut ret = String::new();
|
||||
|
||||
@@ -189,10 +189,11 @@ impl BackupProvider {
|
||||
|
||||
let blobdir = BlobDirContents::new(&context).await?;
|
||||
|
||||
let mut file_size = 0;
|
||||
file_size += dbfile.metadata()?.len();
|
||||
let mut file_size = dbfile.metadata()?.len();
|
||||
for blob in blobdir.iter() {
|
||||
file_size += blob.to_abs_path().metadata()?.len()
|
||||
file_size = file_size
|
||||
.checked_add(blob.to_abs_path().metadata()?.len())
|
||||
.context("File size overflow")?;
|
||||
}
|
||||
|
||||
send_stream.write_all(&file_size.to_be_bytes()).await?;
|
||||
@@ -466,6 +467,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tests that trying to accidentally overwrite a profile
|
||||
/// that is in use will fail.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_cant_overwrite_profile_in_use() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let ctx0 = &tcm.alice().await;
|
||||
let ctx1 = &tcm.bob().await;
|
||||
|
||||
// Prepare to transfer backup.
|
||||
let provider = BackupProvider::prepare(ctx0).await?;
|
||||
|
||||
// Try to overwrite an existing profile.
|
||||
let err = get_backup(ctx1, provider.qr()).await.unwrap_err();
|
||||
assert!(format!("{err:#}").contains("Cannot import backups to accounts in use"));
|
||||
|
||||
// ctx0 is supposed to also finish, and emit an error:
|
||||
provider.await.unwrap();
|
||||
ctx0.evtracker
|
||||
.get_matching(|e| matches!(e, EventType::Error(_)))
|
||||
.await;
|
||||
|
||||
assert_eq!(ctx1.get_primary_self_addr().await?, "bob@example.net");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_drop_provider() {
|
||||
let mut tcm = TestContextManager::new();
|
||||
|
||||
@@ -10,13 +10,12 @@ use crate::key;
|
||||
use crate::key::DcKey;
|
||||
use crate::mimeparser::MimeMessage;
|
||||
use crate::pgp;
|
||||
use crate::pgp::KeyPair;
|
||||
|
||||
pub fn key_from_asc(data: &str) -> Result<key::SignedSecretKey> {
|
||||
key::SignedSecretKey::from_asc(data)
|
||||
}
|
||||
|
||||
pub async fn store_self_keypair(context: &Context, keypair: &KeyPair) -> Result<()> {
|
||||
pub async fn store_self_keypair(context: &Context, keypair: &key::SignedSecretKey) -> Result<()> {
|
||||
key::store_self_keypair(context, keypair).await
|
||||
}
|
||||
|
||||
@@ -29,7 +28,7 @@ pub async fn save_broadcast_secret(context: &Context, chat_id: ChatId, secret: &
|
||||
crate::chat::save_broadcast_secret(context, chat_id, secret).await
|
||||
}
|
||||
|
||||
pub fn create_dummy_keypair(addr: &str) -> Result<KeyPair> {
|
||||
pub fn create_dummy_keypair(addr: &str) -> Result<key::SignedSecretKey> {
|
||||
pgp::create_keypair(EmailAddress::new(addr)?)
|
||||
}
|
||||
|
||||
|
||||
100
src/key.rs
100
src/key.rs
@@ -10,13 +10,12 @@ use deltachat_contact_tools::EmailAddress;
|
||||
use pgp::composed::Deserializable;
|
||||
pub use pgp::composed::{SignedPublicKey, SignedSecretKey};
|
||||
use pgp::ser::Serialize;
|
||||
use pgp::types::{KeyDetails, KeyId};
|
||||
use pgp::types::KeyDetails;
|
||||
use tokio::runtime::Handle;
|
||||
|
||||
use crate::context::Context;
|
||||
use crate::events::EventType;
|
||||
use crate::log::LogExt;
|
||||
use crate::pgp::KeyPair;
|
||||
use crate::tools::{self, time_elapsed};
|
||||
|
||||
/// Convenience trait for working with keys.
|
||||
@@ -113,19 +112,16 @@ pub trait DcKey: Serialize + Deserializable + Clone {
|
||||
|
||||
/// Whether the key is private (or public).
|
||||
fn is_private() -> bool;
|
||||
|
||||
/// Returns the OpenPGP Key ID.
|
||||
fn key_id(&self) -> KeyId;
|
||||
}
|
||||
|
||||
/// Attempts to load own public key.
|
||||
///
|
||||
/// Returns `None` if no key is generated yet.
|
||||
pub(crate) async fn load_self_public_key_opt(context: &Context) -> Result<Option<SignedPublicKey>> {
|
||||
let Some(public_key_bytes) = context
|
||||
let Some(secret_key_bytes) = context
|
||||
.sql
|
||||
.query_row_optional(
|
||||
"SELECT public_key
|
||||
"SELECT private_key
|
||||
FROM keypairs
|
||||
WHERE id=(SELECT value FROM config WHERE keyname='key_id')",
|
||||
(),
|
||||
@@ -138,8 +134,9 @@ pub(crate) async fn load_self_public_key_opt(context: &Context) -> Result<Option
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
|
||||
Ok(Some(public_key))
|
||||
let signed_secret_key = SignedSecretKey::from_slice(&secret_key_bytes)?;
|
||||
let signed_public_key = signed_secret_key.to_public_key();
|
||||
Ok(Some(signed_public_key))
|
||||
}
|
||||
|
||||
/// Loads own public key.
|
||||
@@ -149,8 +146,8 @@ pub(crate) async fn load_self_public_key(context: &Context) -> Result<SignedPubl
|
||||
match load_self_public_key_opt(context).await? {
|
||||
Some(public_key) => Ok(public_key),
|
||||
None => {
|
||||
let keypair = generate_keypair(context).await?;
|
||||
Ok(keypair.public)
|
||||
let signed_secret_key = generate_keypair(context).await?;
|
||||
Ok(signed_secret_key.to_public_key())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,8 +212,8 @@ pub(crate) async fn load_self_secret_key(context: &Context) -> Result<SignedSecr
|
||||
match private_key {
|
||||
Some(bytes) => SignedSecretKey::from_slice(&bytes),
|
||||
None => {
|
||||
let keypair = generate_keypair(context).await?;
|
||||
Ok(keypair.secret)
|
||||
let secret = generate_keypair(context).await?;
|
||||
Ok(secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,10 +259,6 @@ impl DcKey for SignedPublicKey {
|
||||
fn dc_fingerprint(&self) -> Fingerprint {
|
||||
self.fingerprint().into()
|
||||
}
|
||||
|
||||
fn key_id(&self) -> KeyId {
|
||||
KeyDetails::legacy_key_id(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl DcKey for SignedSecretKey {
|
||||
@@ -289,13 +282,9 @@ impl DcKey for SignedSecretKey {
|
||||
fn dc_fingerprint(&self) -> Fingerprint {
|
||||
self.fingerprint().into()
|
||||
}
|
||||
|
||||
fn key_id(&self) -> KeyId {
|
||||
KeyDetails::legacy_key_id(&**self)
|
||||
}
|
||||
}
|
||||
|
||||
async fn generate_keypair(context: &Context) -> Result<KeyPair> {
|
||||
async fn generate_keypair(context: &Context) -> Result<SignedSecretKey> {
|
||||
let addr = context.get_primary_self_addr().await?;
|
||||
let addr = EmailAddress::new(&addr)?;
|
||||
let _guard = context.generating_key_mutex.lock().await;
|
||||
@@ -321,51 +310,46 @@ async fn generate_keypair(context: &Context) -> Result<KeyPair> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn load_keypair(context: &Context) -> Result<Option<KeyPair>> {
|
||||
pub(crate) async fn load_keypair(context: &Context) -> Result<Option<SignedSecretKey>> {
|
||||
let res = context
|
||||
.sql
|
||||
.query_row_optional(
|
||||
"SELECT public_key, private_key
|
||||
FROM keypairs
|
||||
WHERE id=(SELECT value FROM config WHERE keyname='key_id')",
|
||||
"SELECT private_key
|
||||
FROM keypairs
|
||||
WHERE id=(SELECT value FROM config WHERE keyname='key_id')",
|
||||
(),
|
||||
|row| {
|
||||
let pub_bytes: Vec<u8> = row.get(0)?;
|
||||
let sec_bytes: Vec<u8> = row.get(1)?;
|
||||
Ok((pub_bytes, sec_bytes))
|
||||
let sec_bytes: Vec<u8> = row.get(0)?;
|
||||
Ok(sec_bytes)
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(if let Some((pub_bytes, sec_bytes)) = res {
|
||||
Some(KeyPair {
|
||||
public: SignedPublicKey::from_slice(&pub_bytes)?,
|
||||
secret: SignedSecretKey::from_slice(&sec_bytes)?,
|
||||
})
|
||||
let signed_secret_key = if let Some(sec_bytes) = res {
|
||||
Some(SignedSecretKey::from_slice(&sec_bytes)?)
|
||||
} else {
|
||||
None
|
||||
})
|
||||
};
|
||||
|
||||
Ok(signed_secret_key)
|
||||
}
|
||||
|
||||
/// Store the keypair as an owned keypair for addr in the database.
|
||||
/// Stores own keypair in the database and sets it as a default.
|
||||
///
|
||||
/// This will save the keypair as keys for the given address. The
|
||||
/// "self" here refers to the fact that this DC instance owns the
|
||||
/// keypair. Usually `addr` will be [Config::ConfiguredAddr].
|
||||
///
|
||||
/// If either the public or private keys are already present in the
|
||||
/// database, this entry will be removed first regardless of the
|
||||
/// address associated with it. Practically this means saving the
|
||||
/// same key again overwrites it.
|
||||
///
|
||||
/// [Config::ConfiguredAddr]: crate::config::Config::ConfiguredAddr
|
||||
pub(crate) async fn store_self_keypair(context: &Context, keypair: &KeyPair) -> Result<()> {
|
||||
/// Fails if we already have a key, so it is not possible to
|
||||
/// have more than one key for new setups. Existing setups
|
||||
/// may still have more than one key for compatibility.
|
||||
pub(crate) async fn store_self_keypair(
|
||||
context: &Context,
|
||||
signed_secret_key: &SignedSecretKey,
|
||||
) -> Result<()> {
|
||||
let signed_public_key = signed_secret_key.to_public_key();
|
||||
let mut config_cache_lock = context.sql.config_cache.write().await;
|
||||
let new_key_id = context
|
||||
.sql
|
||||
.transaction(|transaction| {
|
||||
let public_key = DcKey::to_bytes(&keypair.public);
|
||||
let secret_key = DcKey::to_bytes(&keypair.secret);
|
||||
let public_key = DcKey::to_bytes(&signed_public_key);
|
||||
let secret_key = DcKey::to_bytes(signed_secret_key);
|
||||
|
||||
// private_key and public_key columns
|
||||
// are UNIQUE since migration 107,
|
||||
@@ -403,9 +387,7 @@ pub(crate) async fn store_self_keypair(context: &Context, keypair: &KeyPair) ->
|
||||
/// Use import/export APIs instead.
|
||||
pub async fn preconfigure_keypair(context: &Context, secret_data: &str) -> Result<()> {
|
||||
let secret = SignedSecretKey::from_asc(secret_data)?;
|
||||
let public = secret.to_public_key();
|
||||
let keypair = KeyPair { public, secret };
|
||||
store_self_keypair(context, &keypair).await?;
|
||||
store_self_keypair(context, &secret).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -484,7 +466,7 @@ mod tests {
|
||||
use crate::config::Config;
|
||||
use crate::test_utils::{TestContext, alice_keypair};
|
||||
|
||||
static KEYPAIR: LazyLock<KeyPair> = LazyLock::new(alice_keypair);
|
||||
static KEYPAIR: LazyLock<SignedSecretKey> = LazyLock::new(alice_keypair);
|
||||
|
||||
#[test]
|
||||
fn test_from_armored_string() {
|
||||
@@ -554,12 +536,12 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
|
||||
|
||||
#[test]
|
||||
fn test_asc_roundtrip() {
|
||||
let key = KEYPAIR.public.clone();
|
||||
let key = KEYPAIR.clone().to_public_key();
|
||||
let asc = key.to_asc(Some(("spam", "ham")));
|
||||
let key2 = SignedPublicKey::from_asc(&asc).unwrap();
|
||||
assert_eq!(key, key2);
|
||||
|
||||
let key = KEYPAIR.secret.clone();
|
||||
let key = KEYPAIR.clone();
|
||||
let asc = key.to_asc(Some(("spam", "ham")));
|
||||
let key2 = SignedSecretKey::from_asc(&asc).unwrap();
|
||||
assert_eq!(key, key2);
|
||||
@@ -567,8 +549,8 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
|
||||
|
||||
#[test]
|
||||
fn test_from_slice_roundtrip() {
|
||||
let public_key = KEYPAIR.public.clone();
|
||||
let private_key = KEYPAIR.secret.clone();
|
||||
let private_key = KEYPAIR.clone();
|
||||
let public_key = KEYPAIR.clone().to_public_key();
|
||||
|
||||
let binary = DcKey::to_bytes(&public_key);
|
||||
let public_key2 = SignedPublicKey::from_slice(&binary).expect("invalid public key");
|
||||
@@ -610,7 +592,7 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
|
||||
b"\x02\xfc\xaa".as_slice(),
|
||||
b"\x01\x02\x03\x04\x05".as_slice(),
|
||||
] {
|
||||
let private_key = KEYPAIR.secret.clone();
|
||||
let private_key = KEYPAIR.clone();
|
||||
|
||||
let mut binary = DcKey::to_bytes(&private_key);
|
||||
binary.extend(garbage);
|
||||
@@ -624,7 +606,7 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
|
||||
|
||||
#[test]
|
||||
fn test_base64_roundtrip() {
|
||||
let key = KEYPAIR.public.clone();
|
||||
let key = KEYPAIR.clone().to_public_key();
|
||||
let base64 = key.to_base64();
|
||||
let key2 = SignedPublicKey::from_base64(&base64).unwrap();
|
||||
assert_eq!(key, key2);
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
clippy::cloned_instead_of_copied,
|
||||
clippy::manual_is_variant_and
|
||||
)]
|
||||
#![cfg_attr(not(test), warn(clippy::arithmetic_side_effects))]
|
||||
#![cfg_attr(not(test), forbid(clippy::indexing_slicing))]
|
||||
#![cfg_attr(not(test), forbid(clippy::string_slice))]
|
||||
#![allow(
|
||||
|
||||
@@ -263,6 +263,7 @@ impl Kml {
|
||||
}
|
||||
|
||||
/// Enables location streaming in chat identified by `chat_id` for `seconds` seconds.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub async fn send_locations_to_chat(
|
||||
context: &Context,
|
||||
chat_id: ChatId,
|
||||
@@ -385,6 +386,7 @@ pub async fn set(context: &Context, latitude: f64, longitude: f64, accuracy: f64
|
||||
}
|
||||
|
||||
/// Searches for locations in the given time range, optionally filtering by chat and contact IDs.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub async fn get_range(
|
||||
context: &Context,
|
||||
chat_id: Option<ChatId>,
|
||||
@@ -517,6 +519,7 @@ pub(crate) async fn delete_orphaned_poi_locations(context: &Context) -> Result<(
|
||||
}
|
||||
|
||||
/// Returns `location.kml` contents.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub async fn get_kml(context: &Context, chat_id: ChatId) -> Result<Option<(String, u32)>> {
|
||||
let mut last_added_location_id = 0;
|
||||
|
||||
@@ -752,6 +755,7 @@ pub(crate) async fn location_loop(context: &Context, interrupt_receiver: Receive
|
||||
|
||||
/// Returns number of seconds until the next time location streaming for some chat ends
|
||||
/// automatically.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn maybe_send_locations(context: &Context) -> Result<Option<u64>> {
|
||||
let mut next_event: Option<u64> = None;
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ impl<S: SessionStream> LoggingStream<S> {
|
||||
}
|
||||
|
||||
impl<S: SessionStream> AsyncRead for LoggingStream<S> {
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
|
||||
@@ -201,6 +201,7 @@ SELECT ?1, rfc724_mid, pre_rfc724_mid, timestamp, ?, ? FROM msgs WHERE id=?1
|
||||
}
|
||||
|
||||
/// Returns detailed message information in a multi-line text form.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub async fn get_info(self, context: &Context) -> Result<String> {
|
||||
let msg = Message::load_from_db(context, self).await?;
|
||||
|
||||
@@ -822,6 +823,7 @@ impl Message {
|
||||
///
|
||||
/// Currently this includes `additional_text`, but this may change in future, when the UIs show
|
||||
/// the necessary info themselves.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub fn get_text(&self) -> String {
|
||||
self.text.clone() + &self.additional_text
|
||||
}
|
||||
@@ -964,6 +966,7 @@ impl Message {
|
||||
///
|
||||
/// A message has a deviating timestamp when it is sent on
|
||||
/// another day as received/sorted by.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub fn has_deviating_timestamp(&self) -> bool {
|
||||
let cnv_to_local = gm2local_offset();
|
||||
let sort_timestamp = self.get_sort_timestamp() + cnv_to_local;
|
||||
@@ -2064,6 +2067,22 @@ pub(crate) async fn set_msg_failed(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inserts a tombstone into `msgs` table
|
||||
/// to prevent downloading the same message in the future.
|
||||
///
|
||||
/// Returns tombstone database row ID.
|
||||
pub(crate) async fn insert_tombstone(context: &Context, rfc724_mid: &str) -> Result<MsgId> {
|
||||
let row_id = context
|
||||
.sql
|
||||
.insert(
|
||||
"INSERT INTO msgs(rfc724_mid, chat_id) VALUES (?,?)",
|
||||
(rfc724_mid, DC_CHAT_ID_TRASH),
|
||||
)
|
||||
.await?;
|
||||
let msg_id = MsgId::new(u32::try_from(row_id)?);
|
||||
Ok(msg_id)
|
||||
}
|
||||
|
||||
/// The number of messages assigned to unblocked chats
|
||||
pub async fn get_unblocked_msg_cnt(context: &Context) -> usize {
|
||||
match context
|
||||
@@ -2119,6 +2138,7 @@ pub async fn get_request_msg_cnt(context: &Context) -> usize {
|
||||
/// Returns the number of messages that are older than the given number of seconds.
|
||||
/// This includes e-mails downloaded due to the `show_emails` option.
|
||||
/// Messages in the "saved messages" folder are not counted as they will not be deleted automatically.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub async fn estimate_deletion_cnt(
|
||||
context: &Context,
|
||||
from_server: bool,
|
||||
|
||||
@@ -195,6 +195,7 @@ fn new_address_with_name(name: &str, address: String) -> Address<'static> {
|
||||
}
|
||||
|
||||
impl MimeFactory {
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub async fn from_msg(context: &Context, msg: Message) -> Result<MimeFactory> {
|
||||
let now = time();
|
||||
let chat = Chat::load_from_db(context, msg.chat_id).await?;
|
||||
@@ -455,9 +456,16 @@ impl MimeFactory {
|
||||
.filter(|id| *id != ContactId::SELF)
|
||||
.collect();
|
||||
if recipient_ids.len() == 1
|
||||
&& msg.param.get_cmd() != SystemMessage::MemberRemovedFromGroup
|
||||
&& chat.typ != Chattype::OutBroadcast
|
||||
&& !matches!(
|
||||
msg.param.get_cmd(),
|
||||
SystemMessage::MemberRemovedFromGroup | SystemMessage::SecurejoinMessage
|
||||
)
|
||||
&& !matches!(chat.typ, Chattype::OutBroadcast | Chattype::InBroadcast)
|
||||
{
|
||||
info!(
|
||||
context,
|
||||
"Scale up origin of {} recipients to OutgoingTo.", chat.id
|
||||
);
|
||||
ContactId::scaleup_origin(context, &recipient_ids, Origin::OutgoingTo).await?;
|
||||
}
|
||||
|
||||
@@ -726,6 +734,7 @@ impl MimeFactory {
|
||||
|
||||
/// Consumes a `MimeFactory` and renders it into a message which is then stored in
|
||||
/// `smtp`-table to be used by the SMTP loop
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub async fn render(mut self, context: &Context) -> Result<RenderedEmail> {
|
||||
let mut headers = Vec::<(&'static str, HeaderType<'static>)>::new();
|
||||
|
||||
@@ -869,16 +878,6 @@ impl MimeFactory {
|
||||
"Auto-Submitted",
|
||||
mail_builder::headers::raw::Raw::new("auto-generated".to_string()).into(),
|
||||
));
|
||||
} else if let Loaded::Message { msg, .. } = &self.loaded
|
||||
&& msg.param.get_cmd() == SystemMessage::SecurejoinMessage
|
||||
{
|
||||
let step = msg.param.get(Param::Arg).unwrap_or_default();
|
||||
if step != "vg-request" && step != "vc-request" {
|
||||
headers.push((
|
||||
"Auto-Submitted",
|
||||
mail_builder::headers::raw::Raw::new("auto-replied".to_string()).into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Loaded::Message { msg, chat } = &self.loaded
|
||||
@@ -947,6 +946,22 @@ impl MimeFactory {
|
||||
));
|
||||
}
|
||||
|
||||
if self.pre_message_mode == PreMessageMode::Post {
|
||||
headers.push((
|
||||
"Chat-Is-Post-Message",
|
||||
mail_builder::headers::raw::Raw::new("1").into(),
|
||||
));
|
||||
} else if let PreMessageMode::Pre {
|
||||
post_msg_rfc724_mid,
|
||||
} = &self.pre_message_mode
|
||||
{
|
||||
headers.push((
|
||||
"Chat-Post-Message-ID",
|
||||
mail_builder::headers::message_id::MessageId::new(post_msg_rfc724_mid.clone())
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
let is_encrypted = self.will_be_encrypted();
|
||||
|
||||
// Add ephemeral timer for non-MDN messages.
|
||||
@@ -993,189 +1008,29 @@ impl MimeFactory {
|
||||
Loaded::Mdn { .. } => self.render_mdn()?,
|
||||
};
|
||||
|
||||
// Split headers based on header confidentiality policy.
|
||||
|
||||
// Headers that must go into IMF header section.
|
||||
//
|
||||
// These are standard headers such as Date, In-Reply-To, References, which cannot be placed
|
||||
// anywhere else according to the standard. Placing headers here also allows them to be fetched
|
||||
// individually over IMAP without downloading the message body. This is why Chat-Version is
|
||||
// placed here.
|
||||
let mut unprotected_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
|
||||
|
||||
// Headers that MUST NOT (only) go into IMF header section:
|
||||
// - Large headers which may hit the header section size limit on the server, such as
|
||||
// Chat-User-Avatar with a base64-encoded image inside.
|
||||
// - Headers duplicated here that servers mess up with in the IMF header section, like
|
||||
// Message-ID.
|
||||
// - Nonstandard headers that should be DKIM-protected because e.g. OpenDKIM only signs
|
||||
// known headers.
|
||||
//
|
||||
// The header should be hidden from MTA
|
||||
// by moving it either into protected part
|
||||
// in case of encrypted mails
|
||||
// or unprotected MIME preamble in case of unencrypted mails.
|
||||
let mut hidden_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
|
||||
|
||||
// Opportunistically protected headers.
|
||||
//
|
||||
// These headers are placed into encrypted part *if* the message is encrypted. Place headers
|
||||
// which are not needed before decryption (e.g. Chat-Group-Name) or are not interesting if the
|
||||
// message cannot be decrypted (e.g. Chat-Disposition-Notification-To) here.
|
||||
//
|
||||
// If the message is not encrypted, these headers are placed into IMF header section, so make
|
||||
// sure that the message will be encrypted if you place any sensitive information here.
|
||||
let mut protected_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
|
||||
|
||||
// MIME header <https://datatracker.ietf.org/doc/html/rfc2045>.
|
||||
unprotected_headers.push((
|
||||
"MIME-Version",
|
||||
mail_builder::headers::raw::Raw::new("1.0").into(),
|
||||
));
|
||||
|
||||
if self.pre_message_mode == PreMessageMode::Post {
|
||||
unprotected_headers.push((
|
||||
"Chat-Is-Post-Message",
|
||||
mail_builder::headers::raw::Raw::new("1").into(),
|
||||
));
|
||||
} else if let PreMessageMode::Pre {
|
||||
post_msg_rfc724_mid,
|
||||
} = &self.pre_message_mode
|
||||
{
|
||||
protected_headers.push((
|
||||
"Chat-Post-Message-ID",
|
||||
mail_builder::headers::message_id::MessageId::new(post_msg_rfc724_mid.clone())
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
for header @ (original_header_name, _header_value) in &headers {
|
||||
let header_name = original_header_name.to_lowercase();
|
||||
if header_name == "message-id" {
|
||||
unprotected_headers.push(header.clone());
|
||||
hidden_headers.push(header.clone());
|
||||
} else if is_hidden(&header_name) {
|
||||
hidden_headers.push(header.clone());
|
||||
} else if header_name == "from" {
|
||||
// Unencrypted securejoin messages should _not_ include the display name:
|
||||
if is_encrypted || !is_securejoin_message {
|
||||
protected_headers.push(header.clone());
|
||||
}
|
||||
|
||||
unprotected_headers.push((
|
||||
original_header_name,
|
||||
Address::new_address(None::<&'static str>, self.from_addr.clone()).into(),
|
||||
));
|
||||
} else if header_name == "to" {
|
||||
protected_headers.push(header.clone());
|
||||
if is_encrypted {
|
||||
unprotected_headers.push(("To", hidden_recipients().into()));
|
||||
} else {
|
||||
unprotected_headers.push(header.clone());
|
||||
}
|
||||
} else if header_name == "chat-broadcast-secret" {
|
||||
if is_encrypted {
|
||||
protected_headers.push(header.clone());
|
||||
} else {
|
||||
bail!("Message is unecrypted, cannot include broadcast secret");
|
||||
}
|
||||
} else if is_encrypted && header_name == "date" {
|
||||
protected_headers.push(header.clone());
|
||||
|
||||
// Randomized date goes to unprotected header.
|
||||
//
|
||||
// We cannot just send "Thu, 01 Jan 1970 00:00:00 +0000"
|
||||
// or omit the header because GMX then fails with
|
||||
//
|
||||
// host mx00.emig.gmx.net[212.227.15.9] said:
|
||||
// 554-Transaction failed
|
||||
// 554-Reject due to policy restrictions.
|
||||
// 554 For explanation visit https://postmaster.gmx.net/en/case?...
|
||||
// (in reply to end of DATA command)
|
||||
//
|
||||
// and the explanation page says
|
||||
// "The time information deviates too much from the actual time".
|
||||
//
|
||||
// We also limit the range to 6 days (518400 seconds)
|
||||
// because with a larger range we got
|
||||
// error "500 Date header far in the past/future"
|
||||
// which apparently originates from Symantec Messaging Gateway
|
||||
// and means the message has a Date that is more
|
||||
// than 7 days in the past:
|
||||
// <https://github.com/chatmail/core/issues/7466>
|
||||
let timestamp_offset = rand::random_range(0..518400);
|
||||
let protected_timestamp = self.timestamp.saturating_sub(timestamp_offset);
|
||||
let unprotected_date =
|
||||
chrono::DateTime::<chrono::Utc>::from_timestamp(protected_timestamp, 0)
|
||||
.unwrap()
|
||||
.to_rfc2822();
|
||||
unprotected_headers.push((
|
||||
"Date",
|
||||
mail_builder::headers::raw::Raw::new(unprotected_date).into(),
|
||||
));
|
||||
} else if is_encrypted {
|
||||
protected_headers.push(header.clone());
|
||||
|
||||
match header_name.as_str() {
|
||||
"subject" => {
|
||||
unprotected_headers.push((
|
||||
"Subject",
|
||||
mail_builder::headers::raw::Raw::new("[...]").into(),
|
||||
));
|
||||
}
|
||||
"in-reply-to"
|
||||
| "references"
|
||||
| "auto-submitted"
|
||||
| "chat-version"
|
||||
| "autocrypt-setup-message" => {
|
||||
unprotected_headers.push(header.clone());
|
||||
}
|
||||
_ => {
|
||||
// Other headers are removed from unprotected part.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Copy the header to the protected headers
|
||||
// in case of signed-only message.
|
||||
// If the message is not signed, this value will not be used.
|
||||
protected_headers.push(header.clone());
|
||||
unprotected_headers.push(header.clone())
|
||||
}
|
||||
}
|
||||
let HeadersByConfidentiality {
|
||||
mut unprotected_headers,
|
||||
hidden_headers,
|
||||
protected_headers,
|
||||
} = group_headers_by_confidentiality(
|
||||
headers,
|
||||
&self.from_addr,
|
||||
self.timestamp,
|
||||
is_encrypted,
|
||||
is_securejoin_message,
|
||||
);
|
||||
|
||||
let use_std_header_protection = context
|
||||
.get_config_bool(Config::StdHeaderProtectionComposing)
|
||||
.await?;
|
||||
let outer_message = if let Some(encryption_pubkeys) = self.encryption_pubkeys {
|
||||
// Store protected headers in the inner message.
|
||||
let message = protected_headers
|
||||
.into_iter()
|
||||
.fold(message, |message, (header, value)| {
|
||||
message.header(header, value)
|
||||
});
|
||||
|
||||
// Add hidden headers to encrypted payload.
|
||||
let mut message: MimePart<'static> = hidden_headers
|
||||
.into_iter()
|
||||
.fold(message, |message, (header, value)| {
|
||||
message.header(header, value)
|
||||
});
|
||||
|
||||
if use_std_header_protection {
|
||||
message = unprotected_headers
|
||||
.iter()
|
||||
// Structural headers shouldn't be added as "HP-Outer". They are defined in
|
||||
// <https://www.rfc-editor.org/rfc/rfc9787.html#structural-header-fields>.
|
||||
.filter(|(name, _)| {
|
||||
!(name.eq_ignore_ascii_case("mime-version")
|
||||
|| name.eq_ignore_ascii_case("content-type")
|
||||
|| name.eq_ignore_ascii_case("content-transfer-encoding")
|
||||
|| name.eq_ignore_ascii_case("content-disposition"))
|
||||
})
|
||||
.fold(message, |message, (name, value)| {
|
||||
message.header(format!("HP-Outer: {name}"), value.clone())
|
||||
});
|
||||
}
|
||||
let mut message = add_headers_to_encrypted_part(
|
||||
message,
|
||||
&unprotected_headers,
|
||||
hidden_headers,
|
||||
protected_headers,
|
||||
use_std_header_protection,
|
||||
);
|
||||
|
||||
// Add gossip headers in chats with multiple recipients
|
||||
let multiple_recipients =
|
||||
@@ -1266,21 +1121,6 @@ impl MimeFactory {
|
||||
}
|
||||
}
|
||||
|
||||
// Set the appropriate Content-Type for the inner message.
|
||||
for (h, v) in &mut message.headers {
|
||||
if h == "Content-Type"
|
||||
&& let mail_builder::headers::HeaderType::ContentType(ct) = v
|
||||
{
|
||||
let mut ct_new = ct.clone();
|
||||
ct_new = ct_new.attribute("protected-headers", "v1");
|
||||
if use_std_header_protection {
|
||||
ct_new = ct_new.attribute("hp", "cipher");
|
||||
}
|
||||
*ct = ct_new;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Disable compression for SecureJoin to ensure
|
||||
// there are no compression side channels
|
||||
// leaking information about the tokens.
|
||||
@@ -1328,8 +1168,9 @@ impl MimeFactory {
|
||||
}
|
||||
|
||||
let encrypted = if let Some(shared_secret) = shared_secret {
|
||||
let sign = true;
|
||||
encrypt_helper
|
||||
.encrypt_symmetrically(context, &shared_secret, message, compress)
|
||||
.encrypt_symmetrically(context, &shared_secret, message, compress, sign)
|
||||
.await?
|
||||
} else {
|
||||
// Asymmetric encryption
|
||||
@@ -1363,35 +1204,7 @@ impl MimeFactory {
|
||||
.await?
|
||||
};
|
||||
|
||||
// XXX: additional newline is needed
|
||||
// to pass filtermail at
|
||||
// <https://github.com/deltachat/chatmail/blob/4d915f9800435bf13057d41af8d708abd34dbfa8/chatmaild/src/chatmaild/filtermail.py#L84-L86>:
|
||||
let encrypted = encrypted + "\n";
|
||||
|
||||
// Set the appropriate Content-Type for the outer message
|
||||
MimePart::new(
|
||||
"multipart/encrypted; protocol=\"application/pgp-encrypted\"",
|
||||
vec![
|
||||
// Autocrypt part 1
|
||||
MimePart::new("application/pgp-encrypted", "Version: 1\r\n").header(
|
||||
"Content-Description",
|
||||
mail_builder::headers::raw::Raw::new("PGP/MIME version identification"),
|
||||
),
|
||||
// Autocrypt part 2
|
||||
MimePart::new(
|
||||
"application/octet-stream; name=\"encrypted.asc\"",
|
||||
encrypted,
|
||||
)
|
||||
.header(
|
||||
"Content-Description",
|
||||
mail_builder::headers::raw::Raw::new("OpenPGP encrypted message"),
|
||||
)
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
mail_builder::headers::raw::Raw::new("inline; filename=\"encrypted.asc\";"),
|
||||
),
|
||||
],
|
||||
)
|
||||
wrap_encrypted_part(encrypted)
|
||||
} else if matches!(self.loaded, Loaded::Mdn { .. }) {
|
||||
// Never add outer multipart/mixed wrapper to MDN
|
||||
// as multipart/report Content-Type is used to recognize MDNs
|
||||
@@ -1458,22 +1271,12 @@ impl MimeFactory {
|
||||
}
|
||||
};
|
||||
|
||||
// Store the unprotected headers on the outer message.
|
||||
let outer_message = unprotected_headers
|
||||
.into_iter()
|
||||
.fold(outer_message, |message, (header, value)| {
|
||||
message.header(header, value)
|
||||
});
|
||||
|
||||
let MimeFactory {
|
||||
last_added_location_id,
|
||||
..
|
||||
} = self;
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
let cursor = Cursor::new(&mut buffer);
|
||||
outer_message.clone().write_part(cursor).ok();
|
||||
let message = String::from_utf8_lossy(&buffer).to_string();
|
||||
let message = render_outer_message(unprotected_headers, outer_message);
|
||||
|
||||
Ok(RenderedEmail {
|
||||
message,
|
||||
@@ -1613,9 +1416,9 @@ impl MimeFactory {
|
||||
.await?
|
||||
.unwrap_or_default()
|
||||
{
|
||||
placeholdertext = Some("I left the group.".to_string());
|
||||
placeholdertext = Some(format!("{email_to_remove} left the group."));
|
||||
} else {
|
||||
placeholdertext = Some(format!("I removed member {email_to_remove}."));
|
||||
placeholdertext = Some(format!("Member {email_to_remove} was removed."));
|
||||
};
|
||||
|
||||
if !email_to_remove.is_empty() {
|
||||
@@ -1638,7 +1441,7 @@ impl MimeFactory {
|
||||
let email_to_add = msg.param.get(Param::Arg).unwrap_or_default();
|
||||
let fingerprint_to_add = msg.param.get(Param::Arg4).unwrap_or_default();
|
||||
|
||||
placeholdertext = Some(format!("I added member {email_to_add}."));
|
||||
placeholdertext = Some(format!("Member {email_to_add} was added."));
|
||||
|
||||
if !email_to_add.is_empty() {
|
||||
headers.push((
|
||||
@@ -1663,6 +1466,7 @@ impl MimeFactory {
|
||||
}
|
||||
}
|
||||
SystemMessage::GroupNameChanged => {
|
||||
placeholdertext = Some("Chat name changed.".to_string());
|
||||
let old_name = msg.param.get(Param::Arg).unwrap_or_default().to_string();
|
||||
headers.push((
|
||||
"Chat-Group-Name-Changed",
|
||||
@@ -1670,12 +1474,16 @@ impl MimeFactory {
|
||||
));
|
||||
}
|
||||
SystemMessage::GroupDescriptionChanged => {
|
||||
placeholdertext = Some(
|
||||
"[Chat description changed. To see this and other new features, please update the app]".to_string(),
|
||||
);
|
||||
headers.push((
|
||||
"Chat-Group-Description-Changed",
|
||||
mail_builder::headers::text::Text::new("").into(),
|
||||
));
|
||||
}
|
||||
SystemMessage::GroupImageChanged => {
|
||||
placeholdertext = Some("Chat image changed.".to_string());
|
||||
headers.push((
|
||||
"Chat-Content",
|
||||
mail_builder::headers::text::Text::new("group-avatar-changed").into(),
|
||||
@@ -1687,7 +1495,24 @@ impl MimeFactory {
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
SystemMessage::Unknown => {}
|
||||
SystemMessage::AutocryptSetupMessage => {}
|
||||
SystemMessage::SecurejoinMessage => {}
|
||||
SystemMessage::LocationStreamingEnabled => {}
|
||||
SystemMessage::LocationOnly => {}
|
||||
SystemMessage::EphemeralTimerChanged => {}
|
||||
SystemMessage::ChatProtectionEnabled => {}
|
||||
SystemMessage::ChatProtectionDisabled => {}
|
||||
SystemMessage::InvalidUnencryptedMail => {}
|
||||
SystemMessage::SecurejoinWait => {}
|
||||
SystemMessage::SecurejoinWaitTimeout => {}
|
||||
SystemMessage::MultiDeviceSync => {}
|
||||
SystemMessage::WebxdcStatusUpdate => {}
|
||||
SystemMessage::WebxdcInfoMessage => {}
|
||||
SystemMessage::IrohNodeAddr => {}
|
||||
SystemMessage::ChatE2ee => {}
|
||||
SystemMessage::CallAccepted => {}
|
||||
SystemMessage::CallEnded => {}
|
||||
}
|
||||
|
||||
if command == SystemMessage::GroupDescriptionChanged
|
||||
@@ -1728,13 +1553,10 @@ impl MimeFactory {
|
||||
| SystemMessage::MultiDeviceSync
|
||||
| SystemMessage::WebxdcStatusUpdate => {
|
||||
// This should prevent automatic replies,
|
||||
// such as non-delivery reports.
|
||||
// such as non-delivery reports,
|
||||
// if the message is unencrypted.
|
||||
//
|
||||
// See <https://tools.ietf.org/html/rfc3834>
|
||||
//
|
||||
// Adding this header without encryption leaks some
|
||||
// information about the message contents, but it can
|
||||
// already be easily guessed from message timing and size.
|
||||
headers.push((
|
||||
"Auto-Submitted",
|
||||
mail_builder::headers::raw::Raw::new("auto-generated").into(),
|
||||
@@ -2069,6 +1891,7 @@ impl MimeFactory {
|
||||
}
|
||||
|
||||
/// Render an MDN
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn render_mdn(&mut self) -> Result<MimePart<'static>> {
|
||||
// RFC 6522, this also requires the `report-type` parameter which is equal
|
||||
// to the MIME subtype of the second body part of the multipart/report
|
||||
@@ -2134,6 +1957,258 @@ impl MimeFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the unprotected headers on the outer message, and renders it.
|
||||
fn render_outer_message(
|
||||
unprotected_headers: Vec<(&'static str, HeaderType<'static>)>,
|
||||
outer_message: MimePart<'static>,
|
||||
) -> String {
|
||||
let outer_message = unprotected_headers
|
||||
.into_iter()
|
||||
.fold(outer_message, |message, (header, value)| {
|
||||
message.header(header, value)
|
||||
});
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
let cursor = Cursor::new(&mut buffer);
|
||||
outer_message.clone().write_part(cursor).ok();
|
||||
String::from_utf8_lossy(&buffer).to_string()
|
||||
}
|
||||
|
||||
/// Takes the encrypted part, wraps it in a MimePart,
|
||||
/// and sets the appropriate Content-Type for the outer message
|
||||
fn wrap_encrypted_part(encrypted: String) -> MimePart<'static> {
|
||||
// XXX: additional newline is needed
|
||||
// to pass filtermail at
|
||||
// <https://github.com/deltachat/chatmail/blob/4d915f9800435bf13057d41af8d708abd34dbfa8/chatmaild/src/chatmaild/filtermail.py#L84-L86>:
|
||||
let encrypted = encrypted + "\n";
|
||||
|
||||
MimePart::new(
|
||||
"multipart/encrypted; protocol=\"application/pgp-encrypted\"",
|
||||
vec![
|
||||
// Autocrypt part 1
|
||||
MimePart::new("application/pgp-encrypted", "Version: 1\r\n").header(
|
||||
"Content-Description",
|
||||
mail_builder::headers::raw::Raw::new("PGP/MIME version identification"),
|
||||
),
|
||||
// Autocrypt part 2
|
||||
MimePart::new(
|
||||
"application/octet-stream; name=\"encrypted.asc\"",
|
||||
encrypted,
|
||||
)
|
||||
.header(
|
||||
"Content-Description",
|
||||
mail_builder::headers::raw::Raw::new("OpenPGP encrypted message"),
|
||||
)
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
mail_builder::headers::raw::Raw::new("inline; filename=\"encrypted.asc\";"),
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn add_headers_to_encrypted_part(
|
||||
message: MimePart<'static>,
|
||||
unprotected_headers: &[(&'static str, HeaderType<'static>)],
|
||||
hidden_headers: Vec<(&'static str, HeaderType<'static>)>,
|
||||
protected_headers: Vec<(&'static str, HeaderType<'static>)>,
|
||||
use_std_header_protection: bool,
|
||||
) -> MimePart<'static> {
|
||||
// Store protected headers in the inner message.
|
||||
let message = protected_headers
|
||||
.into_iter()
|
||||
.fold(message, |message, (header, value)| {
|
||||
message.header(header, value)
|
||||
});
|
||||
|
||||
// Add hidden headers to encrypted payload.
|
||||
let mut message: MimePart<'static> = hidden_headers
|
||||
.into_iter()
|
||||
.fold(message, |message, (header, value)| {
|
||||
message.header(header, value)
|
||||
});
|
||||
|
||||
if use_std_header_protection {
|
||||
message = unprotected_headers
|
||||
.iter()
|
||||
// Structural headers shouldn't be added as "HP-Outer". They are defined in
|
||||
// <https://www.rfc-editor.org/rfc/rfc9787.html#structural-header-fields>.
|
||||
.filter(|(name, _)| {
|
||||
!(name.eq_ignore_ascii_case("mime-version")
|
||||
|| name.eq_ignore_ascii_case("content-type")
|
||||
|| name.eq_ignore_ascii_case("content-transfer-encoding")
|
||||
|| name.eq_ignore_ascii_case("content-disposition"))
|
||||
})
|
||||
.fold(message, |message, (name, value)| {
|
||||
message.header(format!("HP-Outer: {name}"), value.clone())
|
||||
});
|
||||
}
|
||||
|
||||
// Set the appropriate Content-Type for the inner message
|
||||
for (h, v) in &mut message.headers {
|
||||
if h == "Content-Type"
|
||||
&& let mail_builder::headers::HeaderType::ContentType(ct) = v
|
||||
{
|
||||
let mut ct_new = ct.clone();
|
||||
ct_new = ct_new.attribute("protected-headers", "v1");
|
||||
if use_std_header_protection {
|
||||
ct_new = ct_new.attribute("hp", "cipher");
|
||||
}
|
||||
*ct = ct_new;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
message
|
||||
}
|
||||
|
||||
struct HeadersByConfidentiality {
|
||||
/// Headers that must go into IMF header section.
|
||||
///
|
||||
/// These are standard headers such as Date, In-Reply-To, References, which cannot be placed
|
||||
/// anywhere else according to the standard. Placing headers here also allows them to be fetched
|
||||
/// individually over IMAP without downloading the message body. This is why Chat-Version is
|
||||
/// placed here.
|
||||
unprotected_headers: Vec<(&'static str, HeaderType<'static>)>,
|
||||
|
||||
/// Headers that MUST NOT (only) go into IMF header section:
|
||||
/// - Large headers which may hit the header section size limit on the server, such as
|
||||
/// Chat-User-Avatar with a base64-encoded image inside.
|
||||
/// - Headers duplicated here that servers mess up with in the IMF header section, like
|
||||
/// Message-ID.
|
||||
/// - Nonstandard headers that should be DKIM-protected because e.g. OpenDKIM only signs
|
||||
/// known headers.
|
||||
///
|
||||
/// The header should be hidden from MTA
|
||||
/// by moving it either into protected part
|
||||
/// in case of encrypted mails
|
||||
/// or unprotected MIME preamble in case of unencrypted mails.
|
||||
hidden_headers: Vec<(&'static str, HeaderType<'static>)>,
|
||||
|
||||
/// Opportunistically protected headers.
|
||||
///
|
||||
/// These headers are placed into encrypted part *if* the message is encrypted. Place headers
|
||||
/// which are not needed before decryption (e.g. Chat-Group-Name) or are not interesting if the
|
||||
/// message cannot be decrypted (e.g. Chat-Disposition-Notification-To) here.
|
||||
///
|
||||
/// If the message is not encrypted, these headers are placed into IMF header section, so make
|
||||
/// sure that the message will be encrypted if you place any sensitive information here.
|
||||
protected_headers: Vec<(&'static str, HeaderType<'static>)>,
|
||||
}
|
||||
|
||||
/// Split headers based on header confidentiality policy.
|
||||
/// See [`HeadersByConfidentiality`] for more info.
|
||||
fn group_headers_by_confidentiality(
|
||||
headers: Vec<(&'static str, HeaderType<'static>)>,
|
||||
from_addr: &str,
|
||||
timestamp: i64,
|
||||
is_encrypted: bool,
|
||||
is_securejoin_message: bool,
|
||||
) -> HeadersByConfidentiality {
|
||||
let mut unprotected_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
|
||||
let mut hidden_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
|
||||
let mut protected_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
|
||||
|
||||
// MIME header <https://datatracker.ietf.org/doc/html/rfc2045>.
|
||||
unprotected_headers.push((
|
||||
"MIME-Version",
|
||||
mail_builder::headers::raw::Raw::new("1.0").into(),
|
||||
));
|
||||
|
||||
for header @ (original_header_name, _header_value) in &headers {
|
||||
let header_name = original_header_name.to_lowercase();
|
||||
if header_name == "message-id" {
|
||||
unprotected_headers.push(header.clone());
|
||||
hidden_headers.push(header.clone());
|
||||
} else if is_hidden(&header_name) {
|
||||
hidden_headers.push(header.clone());
|
||||
} else if header_name == "from" {
|
||||
// Unencrypted securejoin messages should _not_ include the display name:
|
||||
if is_encrypted || !is_securejoin_message {
|
||||
protected_headers.push(header.clone());
|
||||
}
|
||||
|
||||
unprotected_headers.push((
|
||||
original_header_name,
|
||||
Address::new_address(None::<&'static str>, from_addr.to_string()).into(),
|
||||
));
|
||||
} else if header_name == "to" {
|
||||
protected_headers.push(header.clone());
|
||||
if is_encrypted {
|
||||
unprotected_headers.push(("To", hidden_recipients().into()));
|
||||
} else {
|
||||
unprotected_headers.push(header.clone());
|
||||
}
|
||||
} else if header_name == "chat-broadcast-secret" {
|
||||
if is_encrypted {
|
||||
protected_headers.push(header.clone());
|
||||
}
|
||||
} else if is_encrypted && header_name == "date" {
|
||||
protected_headers.push(header.clone());
|
||||
|
||||
// Randomized date goes to unprotected header.
|
||||
//
|
||||
// We cannot just send "Thu, 01 Jan 1970 00:00:00 +0000"
|
||||
// or omit the header because GMX then fails with
|
||||
//
|
||||
// host mx00.emig.gmx.net[212.227.15.9] said:
|
||||
// 554-Transaction failed
|
||||
// 554-Reject due to policy restrictions.
|
||||
// 554 For explanation visit https://postmaster.gmx.net/en/case?...
|
||||
// (in reply to end of DATA command)
|
||||
//
|
||||
// and the explanation page says
|
||||
// "The time information deviates too much from the actual time".
|
||||
//
|
||||
// We also limit the range to 6 days (518400 seconds)
|
||||
// because with a larger range we got
|
||||
// error "500 Date header far in the past/future"
|
||||
// which apparently originates from Symantec Messaging Gateway
|
||||
// and means the message has a Date that is more
|
||||
// than 7 days in the past:
|
||||
// <https://github.com/chatmail/core/issues/7466>
|
||||
let timestamp_offset = rand::random_range(0..518400);
|
||||
let protected_timestamp = timestamp.saturating_sub(timestamp_offset);
|
||||
let unprotected_date =
|
||||
chrono::DateTime::<chrono::Utc>::from_timestamp(protected_timestamp, 0)
|
||||
.unwrap()
|
||||
.to_rfc2822();
|
||||
unprotected_headers.push((
|
||||
"Date",
|
||||
mail_builder::headers::raw::Raw::new(unprotected_date).into(),
|
||||
));
|
||||
} else if is_encrypted {
|
||||
protected_headers.push(header.clone());
|
||||
|
||||
match header_name.as_str() {
|
||||
"subject" => {
|
||||
unprotected_headers.push((
|
||||
"Subject",
|
||||
mail_builder::headers::raw::Raw::new("[...]").into(),
|
||||
));
|
||||
}
|
||||
"chat-version" | "autocrypt-setup-message" | "chat-is-post-message" => {
|
||||
unprotected_headers.push(header.clone());
|
||||
}
|
||||
_ => {
|
||||
// Other headers are removed from unprotected part.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Copy the header to the protected headers
|
||||
// in case of signed-only message.
|
||||
// If the message is not signed, this value will not be used.
|
||||
protected_headers.push(header.clone());
|
||||
unprotected_headers.push(header.clone())
|
||||
}
|
||||
}
|
||||
HeadersByConfidentiality {
|
||||
unprotected_headers,
|
||||
hidden_headers,
|
||||
protected_headers,
|
||||
}
|
||||
}
|
||||
|
||||
fn hidden_recipients() -> Address<'static> {
|
||||
Address::new_group(Some("hidden-recipients".to_string()), Vec::new())
|
||||
}
|
||||
@@ -2241,5 +2316,114 @@ fn b_encode(value: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn render_symm_encrypted_securejoin_message(
|
||||
context: &Context,
|
||||
step: &str,
|
||||
rfc724_mid: &str,
|
||||
attach_self_pubkey: bool,
|
||||
auth: &str,
|
||||
) -> Result<String> {
|
||||
info!(context, "Sending secure-join message {step:?}.");
|
||||
|
||||
let mut headers = Vec::<(&'static str, HeaderType<'static>)>::new();
|
||||
|
||||
let from_addr = context.get_primary_self_addr().await?;
|
||||
let from = new_address_with_name("", from_addr.to_string());
|
||||
headers.push(("From", from.into()));
|
||||
|
||||
let to: Vec<Address<'static>> = vec![hidden_recipients()];
|
||||
headers.push((
|
||||
"To",
|
||||
mail_builder::headers::address::Address::new_list(to.clone()).into(),
|
||||
));
|
||||
|
||||
headers.push((
|
||||
"Subject",
|
||||
mail_builder::headers::text::Text::new("Secure-Join".to_string()).into(),
|
||||
));
|
||||
|
||||
let timestamp = create_smeared_timestamp(context);
|
||||
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(timestamp, 0)
|
||||
.unwrap()
|
||||
.to_rfc2822();
|
||||
headers.push(("Date", mail_builder::headers::raw::Raw::new(date).into()));
|
||||
|
||||
headers.push((
|
||||
"Message-ID",
|
||||
mail_builder::headers::message_id::MessageId::new(rfc724_mid.to_string()).into(),
|
||||
));
|
||||
|
||||
// Automatic Response headers <https://www.rfc-editor.org/rfc/rfc3834>
|
||||
if context.get_config_bool(Config::Bot).await? {
|
||||
headers.push((
|
||||
"Auto-Submitted",
|
||||
mail_builder::headers::raw::Raw::new("auto-generated".to_string()).into(),
|
||||
));
|
||||
}
|
||||
|
||||
let encrypt_helper = EncryptHelper::new(context).await?;
|
||||
|
||||
if attach_self_pubkey {
|
||||
let aheader = encrypt_helper.get_aheader().to_string();
|
||||
headers.push((
|
||||
"Autocrypt",
|
||||
mail_builder::headers::raw::Raw::new(aheader).into(),
|
||||
));
|
||||
}
|
||||
|
||||
headers.push((
|
||||
"Secure-Join",
|
||||
mail_builder::headers::raw::Raw::new(step.to_string()).into(),
|
||||
));
|
||||
|
||||
headers.push((
|
||||
"Secure-Join-Auth",
|
||||
mail_builder::headers::text::Text::new(auth.to_string()).into(),
|
||||
));
|
||||
|
||||
let message: MimePart<'static> = MimePart::new("text/plain", "Secure-Join");
|
||||
|
||||
let is_encrypted = true;
|
||||
let is_securejoin_message = true;
|
||||
let HeadersByConfidentiality {
|
||||
unprotected_headers,
|
||||
hidden_headers,
|
||||
protected_headers,
|
||||
} = group_headers_by_confidentiality(
|
||||
headers,
|
||||
&from_addr,
|
||||
timestamp,
|
||||
is_encrypted,
|
||||
is_securejoin_message,
|
||||
);
|
||||
|
||||
let outer_message = {
|
||||
let use_std_header_protection = true;
|
||||
let message = add_headers_to_encrypted_part(
|
||||
message,
|
||||
&unprotected_headers,
|
||||
hidden_headers,
|
||||
protected_headers,
|
||||
use_std_header_protection,
|
||||
);
|
||||
|
||||
// Disable compression for SecureJoin to ensure
|
||||
// there are no compression side channels
|
||||
// leaking information about the tokens.
|
||||
let compress = false;
|
||||
// Only sign the message if we attach the pubkey.
|
||||
let sign = attach_self_pubkey;
|
||||
let encrypted = encrypt_helper
|
||||
.encrypt_symmetrically(context, auth, message, compress, sign)
|
||||
.await?;
|
||||
|
||||
wrap_encrypted_part(encrypted)
|
||||
};
|
||||
|
||||
let message = render_outer_message(unprotected_headers, outer_message);
|
||||
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mimefactory_tests;
|
||||
|
||||
@@ -18,10 +18,9 @@ use crate::authres::handle_authres;
|
||||
use crate::blob::BlobObject;
|
||||
use crate::chat::ChatId;
|
||||
use crate::config::Config;
|
||||
use crate::constants;
|
||||
use crate::contact::ContactId;
|
||||
use crate::context::Context;
|
||||
use crate::decrypt::{try_decrypt, validate_detached_signature};
|
||||
use crate::decrypt::{get_encrypted_pgp_message, validate_detached_signature};
|
||||
use crate::dehtml::dehtml;
|
||||
use crate::download::PostMsgMetadata;
|
||||
use crate::events::EventType;
|
||||
@@ -36,6 +35,7 @@ use crate::tools::{
|
||||
get_filemeta, parse_receive_headers, smeared_time, time, truncate_msg_text, validate_id,
|
||||
};
|
||||
use crate::{chatlist_events, location, tools};
|
||||
use crate::{constants, token};
|
||||
|
||||
/// Public key extracted from `Autocrypt-Gossip`
|
||||
/// header with associated information.
|
||||
@@ -266,6 +266,7 @@ impl MimeMessage {
|
||||
///
|
||||
/// This method has some side-effects,
|
||||
/// such as saving blobs and saving found public keys to the database.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub(crate) async fn from_bytes(context: &Context, body: &[u8]) -> Result<Self> {
|
||||
let mail = mailparse::parse_mail(body)?;
|
||||
|
||||
@@ -358,7 +359,8 @@ impl MimeMessage {
|
||||
|
||||
// Remove headers that are allowed _only_ in the encrypted+signed part. It's ok to leave
|
||||
// them in signed-only emails, but has no value currently.
|
||||
Self::remove_secured_headers(&mut headers, &mut headers_removed);
|
||||
let encrypted = false;
|
||||
Self::remove_secured_headers(&mut headers, &mut headers_removed, encrypted);
|
||||
|
||||
let mut from = from.context("No from in message")?;
|
||||
let private_keyring = load_self_secret_keyring(context).await?;
|
||||
@@ -383,59 +385,64 @@ impl MimeMessage {
|
||||
PreMessageMode::None
|
||||
};
|
||||
|
||||
let encrypted_pgp_message = get_encrypted_pgp_message(&mail)?;
|
||||
|
||||
let secrets: Vec<String>;
|
||||
if let Some(e) = &encrypted_pgp_message
|
||||
&& crate::pgp::check_symmetric_encryption(e).is_ok()
|
||||
{
|
||||
secrets = load_shared_secrets(context).await?;
|
||||
} else {
|
||||
secrets = vec![];
|
||||
}
|
||||
|
||||
let mail_raw; // Memory location for a possible decrypted message.
|
||||
let decrypted_msg; // Decrypted signed OpenPGP message.
|
||||
let secrets: Vec<String> = context
|
||||
.sql
|
||||
.query_map_vec("SELECT secret FROM broadcast_secrets", (), |row| {
|
||||
let secret: String = row.get(0)?;
|
||||
Ok(secret)
|
||||
})
|
||||
.await?;
|
||||
|
||||
let (mail, is_encrypted) =
|
||||
match tokio::task::block_in_place(|| try_decrypt(&mail, &private_keyring, &secrets)) {
|
||||
Ok(Some(mut msg)) => {
|
||||
mail_raw = msg.as_data_vec().unwrap_or_default();
|
||||
let (mail, is_encrypted) = match tokio::task::block_in_place(|| {
|
||||
encrypted_pgp_message.map(|e| crate::pgp::decrypt(e, &private_keyring, &secrets))
|
||||
}) {
|
||||
Some(Ok(mut msg)) => {
|
||||
mail_raw = msg.as_data_vec().unwrap_or_default();
|
||||
|
||||
let decrypted_mail = mailparse::parse_mail(&mail_raw)?;
|
||||
if std::env::var(crate::DCC_MIME_DEBUG).is_ok() {
|
||||
info!(
|
||||
context,
|
||||
"decrypted message mime-body:\n{}",
|
||||
String::from_utf8_lossy(&mail_raw),
|
||||
);
|
||||
}
|
||||
|
||||
decrypted_msg = Some(msg);
|
||||
|
||||
timestamp_sent = Self::get_timestamp_sent(
|
||||
&decrypted_mail.headers,
|
||||
timestamp_sent,
|
||||
timestamp_rcvd,
|
||||
let decrypted_mail = mailparse::parse_mail(&mail_raw)?;
|
||||
if std::env::var(crate::DCC_MIME_DEBUG).is_ok() {
|
||||
info!(
|
||||
context,
|
||||
"decrypted message mime-body:\n{}",
|
||||
String::from_utf8_lossy(&mail_raw),
|
||||
);
|
||||
}
|
||||
|
||||
let protected_aheader_values = decrypted_mail
|
||||
.headers
|
||||
.get_all_values(HeaderDef::Autocrypt.into());
|
||||
if !protected_aheader_values.is_empty() {
|
||||
aheader_values = protected_aheader_values;
|
||||
}
|
||||
decrypted_msg = Some(msg);
|
||||
|
||||
(Ok(decrypted_mail), true)
|
||||
timestamp_sent = Self::get_timestamp_sent(
|
||||
&decrypted_mail.headers,
|
||||
timestamp_sent,
|
||||
timestamp_rcvd,
|
||||
);
|
||||
|
||||
let protected_aheader_values = decrypted_mail
|
||||
.headers
|
||||
.get_all_values(HeaderDef::Autocrypt.into());
|
||||
if !protected_aheader_values.is_empty() {
|
||||
aheader_values = protected_aheader_values;
|
||||
}
|
||||
Ok(None) => {
|
||||
mail_raw = Vec::new();
|
||||
decrypted_msg = None;
|
||||
(Ok(mail), false)
|
||||
}
|
||||
Err(err) => {
|
||||
mail_raw = Vec::new();
|
||||
decrypted_msg = None;
|
||||
warn!(context, "decryption failed: {:#}", err);
|
||||
(Err(err), false)
|
||||
}
|
||||
};
|
||||
|
||||
(Ok(decrypted_mail), true)
|
||||
}
|
||||
None => {
|
||||
mail_raw = Vec::new();
|
||||
decrypted_msg = None;
|
||||
(Ok(mail), false)
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
mail_raw = Vec::new();
|
||||
decrypted_msg = None;
|
||||
warn!(context, "decryption failed: {:#}", err);
|
||||
(Err(err), false)
|
||||
}
|
||||
};
|
||||
|
||||
let mut autocrypt_header = None;
|
||||
if incoming {
|
||||
@@ -608,7 +615,7 @@ impl MimeMessage {
|
||||
}
|
||||
}
|
||||
if signatures.is_empty() {
|
||||
Self::remove_secured_headers(&mut headers, &mut headers_removed);
|
||||
Self::remove_secured_headers(&mut headers, &mut headers_removed, is_encrypted);
|
||||
}
|
||||
if !is_encrypted {
|
||||
signatures.clear();
|
||||
@@ -728,6 +735,7 @@ impl MimeMessage {
|
||||
Ok(parser)
|
||||
}
|
||||
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn get_timestamp_sent(
|
||||
hdrs: &[mailparse::MailHeader<'_>],
|
||||
default: i64,
|
||||
@@ -1005,6 +1013,7 @@ impl MimeMessage {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn avatar_action_from_header(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
@@ -1506,6 +1515,7 @@ impl MimeMessage {
|
||||
}
|
||||
|
||||
#[expect(clippy::too_many_arguments)]
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn do_add_single_file_part(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
@@ -1718,20 +1728,37 @@ impl MimeMessage {
|
||||
.and_then(|msgid| parse_message_id(msgid).ok())
|
||||
}
|
||||
|
||||
/// Remove headers that are not allowed in unsigned / unencrypted messages.
|
||||
///
|
||||
/// Pass `encrypted=true` parameter for an encrypted, but unsigned message.
|
||||
/// Pass `encrypted=false` parameter for an unencrypted message.
|
||||
/// Don't call this function if the message was encrypted and signed.
|
||||
fn remove_secured_headers(
|
||||
headers: &mut HashMap<String, String>,
|
||||
removed: &mut HashSet<String>,
|
||||
encrypted: bool,
|
||||
) {
|
||||
remove_header(headers, "secure-join-fingerprint", removed);
|
||||
remove_header(headers, "secure-join-auth", removed);
|
||||
remove_header(headers, "chat-verified", removed);
|
||||
remove_header(headers, "autocrypt-gossip", removed);
|
||||
|
||||
// Secure-Join is secured unless it is an initial "vc-request"/"vg-request".
|
||||
if let Some(secure_join) = remove_header(headers, "secure-join", removed)
|
||||
&& (secure_join == "vc-request" || secure_join == "vg-request")
|
||||
{
|
||||
headers.insert("secure-join".to_string(), secure_join);
|
||||
if headers.get("secure-join") == Some(&"vc-request-pubkey".to_string()) && encrypted {
|
||||
// vc-request-pubkey message is encrypted, but unsigned,
|
||||
// and contains a Secure-Join-Auth header.
|
||||
//
|
||||
// It is unsigned in order not to leak Bob's identity to a server operator
|
||||
// that scraped the AUTH token somewhere from the web,
|
||||
// and because Alice anyways couldn't verify his signature at this step,
|
||||
// because she doesn't know his public key yet.
|
||||
} else {
|
||||
remove_header(headers, "secure-join-auth", removed);
|
||||
|
||||
// Secure-Join is secured unless it is an initial "vc-request"/"vg-request".
|
||||
if let Some(secure_join) = remove_header(headers, "secure-join", removed)
|
||||
&& (secure_join == "vc-request" || secure_join == "vg-request")
|
||||
{
|
||||
headers.insert("secure-join".to_string(), secure_join);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2057,6 +2084,7 @@ impl MimeMessage {
|
||||
/// Returns parsed `Chat-Group-Member-Timestamps` header contents.
|
||||
///
|
||||
/// Returns `None` if there is no such header.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub fn chat_group_member_timestamps(&self) -> Option<Vec<i64>> {
|
||||
let now = time() + constants::TIMESTAMP_SENT_TOLERANCE;
|
||||
self.get_header(HeaderDef::ChatGroupMemberTimestamps)
|
||||
@@ -2082,6 +2110,35 @@ impl MimeMessage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads all the shared secrets
|
||||
/// that will be tried to decrypt a symmetrically-encrypted message
|
||||
async fn load_shared_secrets(context: &Context) -> Result<Vec<String>> {
|
||||
// First, try decrypting using the bobstate,
|
||||
// because usually there will only be 1 or 2 of it,
|
||||
// so, it should be fast
|
||||
let mut secrets: Vec<String> = context
|
||||
.sql
|
||||
.query_map_vec("SELECT invite FROM bobstate", (), |row| {
|
||||
let invite: crate::securejoin::QrInvite = row.get(0)?;
|
||||
Ok(invite.authcode().to_string())
|
||||
})
|
||||
.await?;
|
||||
// Then, try decrypting using broadcast secrets
|
||||
secrets.extend(
|
||||
context
|
||||
.sql
|
||||
.query_map_vec("SELECT secret FROM broadcast_secrets", (), |row| {
|
||||
let secret: String = row.get(0)?;
|
||||
Ok(secret)
|
||||
})
|
||||
.await?,
|
||||
);
|
||||
// Finally, try decrypting using AUTH tokens
|
||||
// There can be a lot of AUTH tokens, because a new one is generated every time a QR code is shown
|
||||
secrets.extend(token::lookup_all(context, token::Namespace::Auth).await?);
|
||||
Ok(secrets)
|
||||
}
|
||||
|
||||
fn rm_legacy_display_elements(text: &str) -> String {
|
||||
let mut res = None;
|
||||
for l in text.lines() {
|
||||
@@ -2539,8 +2596,12 @@ async fn handle_mdn(
|
||||
(msg_id, from_id, timestamp_sent),
|
||||
)
|
||||
.await?;
|
||||
context.emit_event(EventType::MsgRead {
|
||||
chat_id,
|
||||
msg_id,
|
||||
first_time: !has_mdns,
|
||||
});
|
||||
if !has_mdns {
|
||||
context.emit_event(EventType::MsgRead { chat_id, msg_id });
|
||||
// note(treefit): only matters if it is the last message in chat (but probably too expensive to check, debounce also solves it)
|
||||
chatlist_events::emit_chatlist_item_changed(context, chat_id);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::{
|
||||
key,
|
||||
message::{MessageState, MessengerMessage},
|
||||
receive_imf::receive_imf,
|
||||
securejoin::QrInvite,
|
||||
test_utils::{TestContext, TestContextManager},
|
||||
tools::time,
|
||||
};
|
||||
@@ -2156,3 +2157,30 @@ Third alternative.
|
||||
assert_eq!(message.parts[0].typ, Viewtype::Text);
|
||||
assert_eq!(message.parts[0].msg, "Third alternative.");
|
||||
}
|
||||
|
||||
/// Tests that loading a bobstate from an old version of Delta Chat
|
||||
/// (that doesn't have the is_v3 attribute)
|
||||
/// doesn't fail
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_load_shared_secrets_with_legacy_state() -> Result<()> {
|
||||
let alice = &TestContext::new_alice().await;
|
||||
|
||||
alice.sql.execute(
|
||||
r#"INSERT INTO bobstate (invite, next_step, chat_id)
|
||||
VALUES ('{"Contact":{"contact_id":10,"fingerprint":[111,111,111,11,111,11,111,111,111,11,11,111,11,111,111,111,111,111,11,111],"invitenumber":"xxxxxxxxxxxxxxxxxxxxxxxx","authcode":"yyyyyyyyyyyyyyyyyyyyyyyy"}}', 0, 10)"#,
|
||||
()
|
||||
).await?;
|
||||
|
||||
// This call must not fail:
|
||||
load_shared_secrets(alice).await.unwrap();
|
||||
|
||||
let qr: QrInvite = alice
|
||||
.sql
|
||||
.query_get_value("SELECT invite FROM bobstate", ())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(qr.is_v3(), false);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -109,8 +109,8 @@ pub(crate) async fn connect_tcp_inner(
|
||||
) -> Result<Pin<Box<TimeoutStream<TcpStream>>>> {
|
||||
let tcp_stream = timeout(TIMEOUT, TcpStream::connect(addr))
|
||||
.await
|
||||
.context("connection timeout")?
|
||||
.context("connection failure")?;
|
||||
.context("Connection timeout")?
|
||||
.context("Connection failure")?;
|
||||
|
||||
// Disable Nagle's algorithm.
|
||||
tcp_stream.set_nodelay(true)?;
|
||||
|
||||
@@ -118,7 +118,7 @@ where
|
||||
fn http_url_cache_timestamps(url: &str, mimetype: Option<&str>) -> (i64, i64) {
|
||||
let now = time();
|
||||
|
||||
let expires = now + 3600 * 24 * 35;
|
||||
let expires = now.saturating_add(3600 * 24 * 35);
|
||||
let stale = if url.ends_with(".xdc") {
|
||||
// WebXDCs are never stale, they just expire.
|
||||
expires
|
||||
@@ -128,19 +128,19 @@ fn http_url_cache_timestamps(url: &str, mimetype: Option<&str>) -> (i64, i64) {
|
||||
// Policy at <https://operations.osmfoundation.org/policies/tiles/>
|
||||
// requires that we cache tiles for at least 7 days.
|
||||
// Do not revalidate earlier than that.
|
||||
now + 3600 * 24 * 7
|
||||
now.saturating_add(3600 * 24 * 7)
|
||||
} else if mimetype.is_some_and(|s| s.starts_with("image/")) {
|
||||
// Cache images for 1 day.
|
||||
//
|
||||
// As of 2024-12-12 WebXDC icons at <https://webxdc.org/apps/>
|
||||
// use the same path for all app versions,
|
||||
// so may change, but it is not critical if outdated icon is displayed.
|
||||
now + 3600 * 24
|
||||
now.saturating_add(3600 * 24)
|
||||
} else {
|
||||
// Revalidate everything else after 1 hour.
|
||||
//
|
||||
// This includes HTML, CSS and JS.
|
||||
now + 3600
|
||||
now.saturating_add(3600)
|
||||
};
|
||||
(expires, stale)
|
||||
}
|
||||
@@ -173,6 +173,7 @@ async fn http_cache_put(context: &Context, url: &str, response: &Response) -> Re
|
||||
/// Retrieves the binary from HTTP cache.
|
||||
///
|
||||
/// Also returns if the response is stale and should be revalidated in the background.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn http_cache_get(context: &Context, url: &str) -> Result<Option<(Response, bool)>> {
|
||||
let now = time();
|
||||
let Some((blob_name, mimetype, encoding, stale_timestamp)) = context
|
||||
|
||||
@@ -174,6 +174,7 @@ pub enum ProxyConfig {
|
||||
}
|
||||
|
||||
/// Constructs HTTP/1.1 `CONNECT` request for HTTP(S) proxy.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn http_connect_request(host: &str, port: u16, auth: Option<(&str, &str)>) -> String {
|
||||
// According to <https://datatracker.ietf.org/doc/html/rfc7230#section-5.4>
|
||||
// clients MUST send `Host:` header in HTTP/1.1 requests,
|
||||
@@ -322,6 +323,7 @@ impl ProxyConfig {
|
||||
/// config into `proxy_url` if `proxy_url` is unset or empty.
|
||||
///
|
||||
/// Unsets `socks5_host`, `socks5_port`, `socks5_user` and `socks5_password` in any case.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn migrate_socks_config(sql: &Sql) -> Result<()> {
|
||||
if sql.get_raw_config("proxy_url").await?.is_none() {
|
||||
// Load legacy SOCKS5 settings.
|
||||
|
||||
@@ -67,6 +67,7 @@ pub async fn get_oauth2_url(
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub(crate) async fn get_oauth2_access_token(
|
||||
context: &Context,
|
||||
addr: &str,
|
||||
@@ -256,6 +257,7 @@ pub(crate) async fn get_oauth2_addr(
|
||||
}
|
||||
|
||||
impl Oauth2 {
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn from_address(addr: &str) -> Option<Self> {
|
||||
let addr_normalized = normalize_addr(addr);
|
||||
if let Some(domain) = addr_normalized
|
||||
|
||||
@@ -533,6 +533,7 @@ pub(crate) fn iroh_topic_from_str(topic: &str) -> Result<TopicId> {
|
||||
Ok(topic)
|
||||
}
|
||||
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn subscribe_loop(
|
||||
context: &Context,
|
||||
mut stream: iroh_gossip::net::GossipReceiver,
|
||||
|
||||
81
src/pgp.rs
81
src/pgp.rs
@@ -63,34 +63,11 @@ pub fn split_armored_data(buf: &[u8]) -> Result<(BlockType, BTreeMap<String, Str
|
||||
Ok((typ, headers, bytes))
|
||||
}
|
||||
|
||||
/// A PGP keypair.
|
||||
///
|
||||
/// This has it's own struct to be able to keep the public and secret
|
||||
/// keys together as they are one unit.
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct KeyPair {
|
||||
/// Public key.
|
||||
pub public: SignedPublicKey,
|
||||
|
||||
/// Secret key.
|
||||
pub secret: SignedSecretKey,
|
||||
}
|
||||
|
||||
impl KeyPair {
|
||||
/// Creates new keypair from a secret key.
|
||||
///
|
||||
/// Public key is split off the secret key.
|
||||
pub fn new(secret: SignedSecretKey) -> Result<Self> {
|
||||
let public = secret.to_public_key();
|
||||
Ok(Self { public, secret })
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new key pair.
|
||||
///
|
||||
/// Both secret and public key consist of signing primary key and encryption subkey
|
||||
/// as [described in the Autocrypt standard](https://autocrypt.org/level1.html#openpgp-based-key-data).
|
||||
pub(crate) fn create_keypair(addr: EmailAddress) -> Result<KeyPair> {
|
||||
pub(crate) fn create_keypair(addr: EmailAddress) -> Result<SignedSecretKey> {
|
||||
let signing_key_type = PgpKeyType::Ed25519Legacy;
|
||||
let encryption_key_type = PgpKeyType::ECDH(ECCCurve::Curve25519);
|
||||
|
||||
@@ -99,6 +76,7 @@ pub(crate) fn create_keypair(addr: EmailAddress) -> Result<KeyPair> {
|
||||
.key_type(signing_key_type)
|
||||
.can_certify(true)
|
||||
.can_sign(true)
|
||||
.feature_seipd_v2(true)
|
||||
.primary_user_id(user_id)
|
||||
.passphrase(None)
|
||||
.preferred_symmetric_algorithms(smallvec![
|
||||
@@ -135,12 +113,7 @@ pub(crate) fn create_keypair(addr: EmailAddress) -> Result<KeyPair> {
|
||||
.verify_bindings()
|
||||
.context("Invalid secret key generated")?;
|
||||
|
||||
let key_pair = KeyPair::new(secret_key)?;
|
||||
key_pair
|
||||
.public
|
||||
.verify_bindings()
|
||||
.context("Invalid public key generated")?;
|
||||
Ok(key_pair)
|
||||
Ok(secret_key)
|
||||
}
|
||||
|
||||
/// Selects a subkey of the public key to use for encryption.
|
||||
@@ -170,6 +143,7 @@ pub enum SeipdVersion {
|
||||
|
||||
/// Encrypts `plain` text using `public_keys_for_encryption`
|
||||
/// and signs it using `private_key_for_signing`.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub async fn pk_encrypt(
|
||||
plain: Vec<u8>,
|
||||
public_keys_for_encryption: Vec<SignedPublicKey>,
|
||||
@@ -327,13 +301,10 @@ pub fn pk_calc_signature(
|
||||
///
|
||||
/// Returns the decrypted and decompressed message.
|
||||
pub fn decrypt(
|
||||
ctext: Vec<u8>,
|
||||
msg: Message<'static>,
|
||||
private_keys_for_decryption: &[SignedSecretKey],
|
||||
mut shared_secrets: &[String],
|
||||
) -> Result<pgp::composed::Message<'static>> {
|
||||
let cursor = Cursor::new(ctext);
|
||||
let (msg, _headers) = Message::from_armor(cursor)?;
|
||||
|
||||
let skeys: Vec<&SignedSecretKey> = private_keys_for_decryption.iter().collect();
|
||||
let empty_pw = Password::empty();
|
||||
|
||||
@@ -389,7 +360,9 @@ pub fn decrypt(
|
||||
/// with all of the known shared secrets.
|
||||
/// In order to prevent this, we do not try to symmetrically decrypt messages
|
||||
/// that use a string2key algorithm other than 'Salted'.
|
||||
fn check_symmetric_encryption(msg: &Message<'_>) -> std::result::Result<(), &'static str> {
|
||||
pub(crate) fn check_symmetric_encryption(
|
||||
msg: &Message<'_>,
|
||||
) -> std::result::Result<(), &'static str> {
|
||||
let Message::Encrypted { esk, .. } = msg else {
|
||||
return Err("not encrypted");
|
||||
};
|
||||
@@ -480,7 +453,7 @@ pub async fn symm_encrypt_autocrypt_setup(passphrase: &str, plain: Vec<u8>) -> R
|
||||
/// `shared secret` is the secret that will be used for symmetric encryption.
|
||||
pub async fn symm_encrypt_message(
|
||||
plain: Vec<u8>,
|
||||
private_key_for_signing: SignedSecretKey,
|
||||
private_key_for_signing: Option<SignedSecretKey>,
|
||||
shared_secret: &str,
|
||||
compress: bool,
|
||||
) -> Result<String> {
|
||||
@@ -503,8 +476,10 @@ pub async fn symm_encrypt_message(
|
||||
);
|
||||
msg.encrypt_with_password(&mut rng, s2k, &shared_secret)?;
|
||||
|
||||
let hash_algorithm = private_key_for_signing.hash_alg();
|
||||
msg.sign(&*private_key_for_signing, Password::empty(), hash_algorithm);
|
||||
if let Some(private_key_for_signing) = private_key_for_signing.as_deref() {
|
||||
let hash_algorithm = private_key_for_signing.hash_alg();
|
||||
msg.sign(private_key_for_signing, Password::empty(), hash_algorithm);
|
||||
}
|
||||
if compress {
|
||||
msg.compression(CompressionAlgorithm::ZLIB);
|
||||
}
|
||||
@@ -546,6 +521,16 @@ mod tests {
|
||||
use pgp::composed::Esk;
|
||||
use pgp::packet::PublicKeyEncryptedSessionKey;
|
||||
|
||||
fn decrypt_bytes(
|
||||
bytes: Vec<u8>,
|
||||
private_keys_for_decryption: &[SignedSecretKey],
|
||||
shared_secrets: &[String],
|
||||
) -> Result<pgp::composed::Message<'static>> {
|
||||
let cursor = Cursor::new(bytes);
|
||||
let (msg, _headers) = Message::from_armor(cursor).unwrap();
|
||||
decrypt(msg, private_keys_for_decryption, shared_secrets)
|
||||
}
|
||||
|
||||
#[expect(clippy::type_complexity)]
|
||||
fn pk_decrypt_and_validate<'a>(
|
||||
ctext: &'a [u8],
|
||||
@@ -556,7 +541,7 @@ mod tests {
|
||||
HashMap<Fingerprint, Vec<Fingerprint>>,
|
||||
Vec<u8>,
|
||||
)> {
|
||||
let mut msg = decrypt(ctext.to_vec(), private_keys_for_decryption, &[])?;
|
||||
let mut msg = decrypt_bytes(ctext.to_vec(), private_keys_for_decryption, &[])?;
|
||||
let content = msg.as_data_vec()?;
|
||||
let ret_signature_fingerprints =
|
||||
valid_signature_fingerprints(&msg, public_keys_for_validation);
|
||||
@@ -595,7 +580,7 @@ mod tests {
|
||||
fn test_create_keypair() {
|
||||
let keypair0 = create_keypair(EmailAddress::new("foo@bar.de").unwrap()).unwrap();
|
||||
let keypair1 = create_keypair(EmailAddress::new("two@zwo.de").unwrap()).unwrap();
|
||||
assert_ne!(keypair0.public, keypair1.public);
|
||||
assert_ne!(keypair0.public_key(), keypair1.public_key());
|
||||
}
|
||||
|
||||
/// [SignedSecretKey] and [SignedPublicKey] objects
|
||||
@@ -612,10 +597,10 @@ mod tests {
|
||||
let alice = alice_keypair();
|
||||
let bob = bob_keypair();
|
||||
TestKeys {
|
||||
alice_secret: alice.secret.clone(),
|
||||
alice_public: alice.public,
|
||||
bob_secret: bob.secret.clone(),
|
||||
bob_public: bob.public,
|
||||
alice_secret: alice.clone(),
|
||||
alice_public: alice.to_public_key(),
|
||||
bob_secret: bob.clone(),
|
||||
bob_public: bob.to_public_key(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -737,14 +722,14 @@ mod tests {
|
||||
let shared_secret = "shared secret";
|
||||
let ctext = symm_encrypt_message(
|
||||
plain.clone(),
|
||||
load_self_secret_key(alice).await?,
|
||||
Some(load_self_secret_key(alice).await?),
|
||||
shared_secret,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
|
||||
let mut decrypted = decrypt(
|
||||
let mut decrypted = decrypt_bytes(
|
||||
ctext.into(),
|
||||
&bob_private_keyring,
|
||||
&[shared_secret.to_string()],
|
||||
@@ -787,7 +772,7 @@ mod tests {
|
||||
// Trying to decrypt it should fail with a helpful error message:
|
||||
|
||||
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
|
||||
let error = decrypt(
|
||||
let error = decrypt_bytes(
|
||||
ctext.into(),
|
||||
&bob_private_keyring,
|
||||
&[shared_secret.to_string()],
|
||||
@@ -824,7 +809,7 @@ mod tests {
|
||||
|
||||
// Trying to decrypt it should fail with an OK error message:
|
||||
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
|
||||
let error = decrypt(ctext.into(), &bob_private_keyring, &[]).unwrap_err();
|
||||
let error = decrypt_bytes(ctext.into(), &bob_private_keyring, &[]).unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
|
||||
@@ -24,6 +24,7 @@ pub struct PlainText {
|
||||
impl PlainText {
|
||||
/// Convert plain text to HTML.
|
||||
/// The function handles quotes, links, fixed and floating text paragraphs.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub fn to_html(&self) -> String {
|
||||
static LINKIFY_MAIL_RE: LazyLock<regex::Regex> =
|
||||
LazyLock::new(|| regex::Regex::new(r"\b([\w.\-+]+@[\w.\-]+)\b").unwrap());
|
||||
|
||||
32
src/qr.rs
32
src/qr.rs
@@ -61,6 +61,9 @@ pub enum Qr {
|
||||
|
||||
/// Authentication code.
|
||||
authcode: String,
|
||||
|
||||
/// Whether the inviter supports the new Securejoin v3 protocol
|
||||
is_v3: bool,
|
||||
},
|
||||
|
||||
/// Ask the user whether to join the group.
|
||||
@@ -82,6 +85,9 @@ pub enum Qr {
|
||||
|
||||
/// Authentication code.
|
||||
authcode: String,
|
||||
|
||||
/// Whether the inviter supports the new Securejoin v3 protocol
|
||||
is_v3: bool,
|
||||
},
|
||||
|
||||
/// Ask whether to join the broadcast channel.
|
||||
@@ -106,6 +112,9 @@ pub enum Qr {
|
||||
invitenumber: String,
|
||||
/// Authentication code.
|
||||
authcode: String,
|
||||
|
||||
/// Whether the inviter supports the new Securejoin v3 protocol
|
||||
is_v3: bool,
|
||||
},
|
||||
|
||||
/// Contact fingerprint is verified.
|
||||
@@ -483,7 +492,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
|
||||
let name = decode_name(¶m, "n")?.unwrap_or_default();
|
||||
|
||||
let invitenumber = param
|
||||
let mut invitenumber = param
|
||||
.get("i")
|
||||
// For historic reansons, broadcasts currently use j instead of i for the invitenumber:
|
||||
.or_else(|| param.get("j"))
|
||||
@@ -501,6 +510,16 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
let grpname = decode_name(¶m, "g")?;
|
||||
let broadcast_name = decode_name(¶m, "b")?;
|
||||
|
||||
let mut is_v3 = param.get("v") == Some(&"3");
|
||||
|
||||
if authcode.is_some() && invitenumber.is_none() {
|
||||
// Securejoin v3 doesn't need an invitenumber.
|
||||
// We want to remove the invitenumber and the `v=3` parameter eventually;
|
||||
// therefore, we accept v3 QR codes without an invitenumber.
|
||||
is_v3 = true;
|
||||
invitenumber = Some("".to_string());
|
||||
}
|
||||
|
||||
if let (Some(addr), Some(invitenumber), Some(authcode)) = (&addr, invitenumber, authcode) {
|
||||
let addr = ContactAddress::new(addr)?;
|
||||
let (contact_id, _) = Contact::add_or_lookup_ex(
|
||||
@@ -519,7 +538,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
.await
|
||||
.with_context(|| format!("can't check if address {addr:?} is our address"))?
|
||||
{
|
||||
if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
|
||||
if token::exists(context, token::Namespace::Auth, &authcode).await? {
|
||||
Ok(Qr::WithdrawVerifyGroup {
|
||||
grpname,
|
||||
grpid,
|
||||
@@ -546,6 +565,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
fingerprint,
|
||||
invitenumber,
|
||||
authcode,
|
||||
is_v3,
|
||||
})
|
||||
}
|
||||
} else if let (Some(grpid), Some(name)) = (grpid, broadcast_name) {
|
||||
@@ -554,7 +574,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
.await
|
||||
.with_context(|| format!("Can't check if {addr:?} is our address"))?
|
||||
{
|
||||
if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
|
||||
if token::exists(context, token::Namespace::Auth, &authcode).await? {
|
||||
Ok(Qr::WithdrawJoinBroadcast {
|
||||
name,
|
||||
grpid,
|
||||
@@ -581,10 +601,11 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
fingerprint,
|
||||
invitenumber,
|
||||
authcode,
|
||||
is_v3,
|
||||
})
|
||||
}
|
||||
} else if context.is_self_addr(&addr).await? {
|
||||
if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
|
||||
if token::exists(context, token::Namespace::Auth, &authcode).await? {
|
||||
Ok(Qr::WithdrawVerifyContact {
|
||||
contact_id,
|
||||
fingerprint,
|
||||
@@ -605,6 +626,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
fingerprint,
|
||||
invitenumber,
|
||||
authcode,
|
||||
is_v3,
|
||||
})
|
||||
}
|
||||
} else if let Some(addr) = addr {
|
||||
@@ -680,6 +702,7 @@ fn decode_account(qr: &str) -> Result<Qr> {
|
||||
}
|
||||
|
||||
/// scheme: `https://t.me/socks?server=foo&port=123` or `https://t.me/socks?server=1.2.3.4&port=123`
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result<Qr> {
|
||||
let url = url::Url::parse(qr).context("Invalid t.me/socks url")?;
|
||||
|
||||
@@ -1021,6 +1044,7 @@ async fn decode_smtp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
/// Scheme: `MATMSG:TO:addr...;SUB:subject...;BODY:body...;`
|
||||
///
|
||||
/// There may or may not be linebreaks after the fields.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn decode_matmsg(context: &Context, qr: &str) -> Result<Qr> {
|
||||
// Does not work when the text `TO:` is used in subject/body _and_ TO: is not the first field.
|
||||
// we ignore this case.
|
||||
|
||||
@@ -81,9 +81,14 @@ pub(super) fn decode_login(qr: &str) -> Result<Qr> {
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect();
|
||||
|
||||
let addr = percent_encoding::percent_decode_str(addr)
|
||||
.decode_utf8()
|
||||
.context("Address must be UTF-8")?
|
||||
.to_string();
|
||||
|
||||
// check if username is there
|
||||
if !may_be_valid_addr(addr) {
|
||||
bail!("invalid DCLOGIN payload: invalid username E5");
|
||||
if !may_be_valid_addr(&addr) {
|
||||
bail!("Invalid DCLOGIN payload: invalid username {addr:?}.");
|
||||
}
|
||||
|
||||
// apply to result struct
|
||||
@@ -200,9 +205,7 @@ pub(crate) fn login_param_from_login_qr(
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use anyhow::bail;
|
||||
|
||||
use super::{LoginOptions, decode_login};
|
||||
use super::*;
|
||||
use crate::{login_param::EnteredCertificateChecks, provider::Socket, qr::Qr};
|
||||
|
||||
macro_rules! login_options_just_pw {
|
||||
@@ -225,7 +228,7 @@ mod test {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_no_options() -> anyhow::Result<()> {
|
||||
fn minimal_no_options() -> Result<()> {
|
||||
let result = decode_login("dclogin://email@host.tld?p=123&v=1")?;
|
||||
if let Qr::Login { address, options } = result {
|
||||
assert_eq!(address, "email@host.tld".to_owned());
|
||||
@@ -250,7 +253,7 @@ mod test {
|
||||
Ok(())
|
||||
}
|
||||
#[test]
|
||||
fn minimal_no_options_no_double_slash() -> anyhow::Result<()> {
|
||||
fn minimal_no_options_no_double_slash() -> Result<()> {
|
||||
let result = decode_login("dclogin:email@host.tld?p=123&v=1")?;
|
||||
if let Qr::Login { address, options } = result {
|
||||
assert_eq!(address, "email@host.tld".to_owned());
|
||||
@@ -289,7 +292,7 @@ mod test {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_too_new() -> anyhow::Result<()> {
|
||||
fn version_too_new() -> Result<()> {
|
||||
let result = decode_login("dclogin:email@host.tld/?p=123456&v=2")?;
|
||||
if let Qr::Login { options, .. } = result {
|
||||
assert_eq!(options, LoginOptions::UnsuportedVersion(2));
|
||||
@@ -306,7 +309,7 @@ mod test {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_advanced_options() -> anyhow::Result<()> {
|
||||
fn all_advanced_options() -> Result<()> {
|
||||
let result = decode_login(
|
||||
"dclogin:email@host.tld?p=secret&v=1&ih=imap.host.tld&ip=4000&iu=max&ipw=87654&is=ssl&ic=1&sh=mail.host.tld&sp=3000&su=max@host.tld&spw=3242HS&ss=plain&sc=3",
|
||||
)?;
|
||||
@@ -336,7 +339,19 @@ mod test {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uri_encoded_password() -> anyhow::Result<()> {
|
||||
fn uri_encoded_login() -> Result<()> {
|
||||
let result = decode_login("dclogin:username@%5b192.168.1.1%5d?p=1234&v=1")?;
|
||||
if let Qr::Login { address, options } = result {
|
||||
assert_eq!(address, "username@[192.168.1.1]".to_owned());
|
||||
assert_eq!(options, login_options_just_pw!("1234".to_owned()));
|
||||
} else {
|
||||
bail!("wrong type")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uri_encoded_password() -> Result<()> {
|
||||
let result = decode_login(
|
||||
"dclogin:email@host.tld?p=%7BDaehFl%3B%22as%40%21fhdodn5%24234%22%7B%7Dfg&v=1",
|
||||
)?;
|
||||
@@ -353,7 +368,7 @@ mod test {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_with_plus_extension() -> anyhow::Result<()> {
|
||||
fn email_with_plus_extension() -> Result<()> {
|
||||
let result = decode_login("dclogin:usename+extension@host?p=1234&v=1")?;
|
||||
if let Qr::Login { address, options } = result {
|
||||
assert_eq!(address, "usename+extension@host".to_owned());
|
||||
@@ -365,7 +380,7 @@ mod test {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_decode_dclogin_ipv4() -> anyhow::Result<()> {
|
||||
async fn test_decode_dclogin_ipv4() -> Result<()> {
|
||||
let result = decode_login("dclogin://test@[127.0.0.1]?p=1234&v=1")?;
|
||||
if let Qr::Login { address, options } = result {
|
||||
assert_eq!(address, "test@[127.0.0.1]".to_owned());
|
||||
@@ -377,7 +392,7 @@ mod test {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_decode_dclogin_ipv6() -> anyhow::Result<()> {
|
||||
async fn test_decode_dclogin_ipv6() -> Result<()> {
|
||||
let result =
|
||||
decode_login("dclogin://test@[2001:0db8:85a3:0000:0000:8a2e:0370:7334]?p=1234&v=1")?;
|
||||
if let Qr::Login { address, options } = result {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use regex::Regex;
|
||||
|
||||
use super::*;
|
||||
use crate::chat::{Chat, create_broadcast, create_group, get_chat_contacts};
|
||||
use crate::config::Config;
|
||||
@@ -445,9 +447,28 @@ async fn test_decode_openpgp_without_addr() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_withdraw_verifycontact() -> Result<()> {
|
||||
async fn test_withdraw_verifycontact_basic() -> Result<()> {
|
||||
test_withdraw_verifycontact(false).await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_withdraw_verifycontact_without_invite() -> Result<()> {
|
||||
test_withdraw_verifycontact(true).await
|
||||
}
|
||||
|
||||
async fn test_withdraw_verifycontact(remove_invite: bool) -> Result<()> {
|
||||
let alice = TestContext::new_alice().await;
|
||||
let qr = get_securejoin_qr(&alice, None).await?;
|
||||
let mut qr = get_securejoin_qr(&alice, None).await?;
|
||||
|
||||
if remove_invite {
|
||||
// Remove the INVITENUBMER. It's not needed in Securejoin v3,
|
||||
// but still included for backwards compatibility reasons.
|
||||
// We want to be able to remove it in the future,
|
||||
// therefore we test that things work without it.
|
||||
let new_qr = Regex::new("&i=.*?&").unwrap().replace(&qr, "&");
|
||||
assert!(new_qr != *qr);
|
||||
qr = new_qr.to_string();
|
||||
}
|
||||
|
||||
// scanning own verify-contact code offers withdrawing
|
||||
assert!(matches!(
|
||||
@@ -466,6 +487,11 @@ async fn test_withdraw_verifycontact() -> Result<()> {
|
||||
check_qr(&alice, &qr).await?,
|
||||
Qr::WithdrawVerifyContact { .. }
|
||||
));
|
||||
// Test that removing the INVITENUMBER doesn't result in saving empty token:
|
||||
assert_eq!(
|
||||
token::exists(&alice, token::Namespace::InviteNumber, "").await?,
|
||||
false
|
||||
);
|
||||
|
||||
// someone else always scans as ask-verify-contact
|
||||
let bob = TestContext::new_bob().await;
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::securejoin;
|
||||
use crate::stock_str::{self, backup_transfer_qr};
|
||||
|
||||
/// Create a QR code from any input data.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub fn create_qr_svg(qrcode_content: &str) -> Result<String> {
|
||||
let all_size = 512.0;
|
||||
let qr_code_size = 416.0;
|
||||
@@ -175,6 +176,7 @@ async fn self_info(context: &Context) -> Result<(Option<Vec<u8>>, String, String
|
||||
Ok((avatar, displayname, addr, color))
|
||||
}
|
||||
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn inner_generate_secure_join_qr_code(
|
||||
qrcode_description: &str,
|
||||
qrcode_content: &str,
|
||||
|
||||
@@ -122,6 +122,7 @@ impl Reactions {
|
||||
}
|
||||
|
||||
/// Returns a map from emojis to their frequencies.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub fn emoji_frequencies(&self) -> BTreeMap<String, usize> {
|
||||
let mut emoji_frequencies: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for reaction in self.reactions.values() {
|
||||
|
||||
@@ -31,7 +31,8 @@ use crate::key::{
|
||||
};
|
||||
use crate::log::{LogExt as _, warn};
|
||||
use crate::message::{
|
||||
self, Message, MessageState, MessengerMessage, MsgId, Viewtype, rfc724_mid_exists,
|
||||
self, Message, MessageState, MessengerMessage, MsgId, Viewtype, insert_tombstone,
|
||||
rfc724_mid_exists,
|
||||
};
|
||||
use crate::mimeparser::{
|
||||
AvatarAction, GossipedKey, MimeMessage, PreMessageMode, SystemMessage, parse_message_ids,
|
||||
@@ -178,22 +179,6 @@ pub(crate) async fn receive_imf_from_inbox(
|
||||
receive_imf_inner(context, rfc724_mid, imf_raw, seen).await
|
||||
}
|
||||
|
||||
/// Inserts a tombstone into `msgs` table
|
||||
/// to prevent downloading the same message in the future.
|
||||
///
|
||||
/// Returns tombstone database row ID.
|
||||
async fn insert_tombstone(context: &Context, rfc724_mid: &str) -> Result<MsgId> {
|
||||
let row_id = context
|
||||
.sql
|
||||
.insert(
|
||||
"INSERT INTO msgs(rfc724_mid, chat_id) VALUES (?,?)",
|
||||
(rfc724_mid, DC_CHAT_ID_TRASH),
|
||||
)
|
||||
.await?;
|
||||
let msg_id = MsgId::new(u32::try_from(row_id)?);
|
||||
Ok(msg_id)
|
||||
}
|
||||
|
||||
async fn get_to_and_past_contact_ids(
|
||||
context: &Context,
|
||||
mime_parser: &MimeMessage,
|
||||
@@ -554,9 +539,17 @@ pub(crate) async fn receive_imf_inner(
|
||||
.await?
|
||||
.filter(|msg| msg.download_state() != DownloadState::Done)
|
||||
{
|
||||
// the message was partially downloaded before and is fully downloaded now.
|
||||
info!(context, "Message already partly in DB, replacing.");
|
||||
Some(msg.chat_id)
|
||||
// The message was partially downloaded before.
|
||||
match mime_parser.pre_message {
|
||||
PreMessageMode::Post | PreMessageMode::None => {
|
||||
info!(context, "Message already partly in DB, replacing.");
|
||||
Some(msg.chat_id)
|
||||
}
|
||||
PreMessageMode::Pre { .. } => {
|
||||
info!(context, "Cannot replace pre-message with a pre-message");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// The message was already fully downloaded
|
||||
// or cannot be loaded because it is deleted.
|
||||
@@ -887,7 +880,11 @@ UPDATE config SET value=? WHERE keyname='configured_addr' AND value!=?1
|
||||
.is_some()
|
||||
{
|
||||
can_info_msg = false;
|
||||
Some(Message::load_from_db(context, insert_msg_id).await?)
|
||||
Some(
|
||||
Message::load_from_db(context, insert_msg_id)
|
||||
.await
|
||||
.context("Failed to load just created webxdc instance")?,
|
||||
)
|
||||
} else if let Some(field) = mime_parser.get_header(HeaderDef::InReplyTo) {
|
||||
if let Some(instance) =
|
||||
message::get_by_rfc724_mids(context, &parse_message_ids(field)).await?
|
||||
@@ -1185,6 +1182,7 @@ pub async fn from_field_to_contact_id(
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn decide_chat_assignment(
|
||||
context: &Context,
|
||||
mime_parser: &MimeMessage,
|
||||
@@ -2136,7 +2134,9 @@ async fn add_parts(
|
||||
}
|
||||
|
||||
if let Some(replace_msg_id) = replace_msg_id {
|
||||
let placeholder = Message::load_from_db(context, replace_msg_id).await?;
|
||||
let placeholder = Message::load_from_db(context, replace_msg_id)
|
||||
.await
|
||||
.context("Failed to load placeholder message")?;
|
||||
for key in [
|
||||
Param::WebxdcSummary,
|
||||
Param::WebxdcSummaryTimestamp,
|
||||
@@ -2919,6 +2919,7 @@ async fn create_group(
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn update_chats_contacts_timestamps(
|
||||
context: &Context,
|
||||
chat_id: ChatId,
|
||||
@@ -3331,8 +3332,13 @@ async fn apply_chat_name_avatar_and_description_changes(
|
||||
.is_some()
|
||||
{
|
||||
let old_name = &sanitize_single_line(old_name);
|
||||
better_msg
|
||||
.get_or_insert(stock_str::msg_grp_name(context, old_name, grpname, from_id).await);
|
||||
better_msg.get_or_insert(
|
||||
if matches!(chat.typ, Chattype::InBroadcast | Chattype::OutBroadcast) {
|
||||
stock_str::msg_broadcast_name_changed(context, old_name, grpname).await
|
||||
} else {
|
||||
stock_str::msg_grp_name(context, old_name, grpname, from_id).await
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3389,10 +3395,18 @@ async fn apply_chat_name_avatar_and_description_changes(
|
||||
{
|
||||
// this is just an explicit message containing the group-avatar,
|
||||
// apart from that, the group-avatar is send along with various other messages
|
||||
better_msg.get_or_insert(match avatar_action {
|
||||
AvatarAction::Delete => stock_str::msg_grp_img_deleted(context, from_id).await,
|
||||
AvatarAction::Change(_) => stock_str::msg_grp_img_changed(context, from_id).await,
|
||||
});
|
||||
better_msg.get_or_insert(
|
||||
if matches!(chat.typ, Chattype::InBroadcast | Chattype::OutBroadcast) {
|
||||
stock_str::msg_broadcast_img_changed(context).await
|
||||
} else {
|
||||
match avatar_action {
|
||||
AvatarAction::Delete => stock_str::msg_grp_img_deleted(context, from_id).await,
|
||||
AvatarAction::Change(_) => {
|
||||
stock_str::msg_grp_img_changed(context, from_id).await
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(avatar_action) = &mime_parser.group_avatar {
|
||||
@@ -3418,6 +3432,7 @@ async fn apply_chat_name_avatar_and_description_changes(
|
||||
}
|
||||
|
||||
/// Returns a list of strings that should be shown as info messages, informing about group membership changes.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn group_changes_msgs(
|
||||
context: &Context,
|
||||
added_ids: &HashSet<ContactId>,
|
||||
@@ -3707,6 +3722,7 @@ async fn apply_out_broadcast_changes(
|
||||
|
||||
let mut send_event_chat_modified = false;
|
||||
let mut better_msg = None;
|
||||
let mut added_removed_id: Option<ContactId> = None;
|
||||
|
||||
if from_id == ContactId::SELF {
|
||||
apply_chat_name_avatar_and_description_changes(
|
||||
@@ -3739,6 +3755,7 @@ async fn apply_out_broadcast_changes(
|
||||
stock_str::msg_add_member_local(context, added_id, ContactId::UNDEFINED)
|
||||
.await;
|
||||
better_msg.get_or_insert(msg);
|
||||
added_removed_id = Some(added_id);
|
||||
send_event_chat_modified = true;
|
||||
}
|
||||
} else {
|
||||
@@ -3763,6 +3780,7 @@ async fn apply_out_broadcast_changes(
|
||||
better_msg.get_or_insert(
|
||||
stock_str::msg_del_member_local(context, removed_id, ContactId::SELF).await,
|
||||
);
|
||||
added_removed_id = Some(removed_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3772,7 +3790,7 @@ async fn apply_out_broadcast_changes(
|
||||
}
|
||||
Ok(GroupChangesInfo {
|
||||
better_msg,
|
||||
added_removed_id: None,
|
||||
added_removed_id,
|
||||
silent: false,
|
||||
extra_msgs: vec![],
|
||||
})
|
||||
|
||||
@@ -431,14 +431,6 @@ pub async fn convert_folder_meaning(
|
||||
}
|
||||
|
||||
async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session) -> Result<Session> {
|
||||
if !ctx.get_config_bool(Config::FixIsChatmail).await? {
|
||||
ctx.set_config_internal(
|
||||
Config::IsChatmail,
|
||||
crate::config::from_bool(session.is_chatmail()),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Update quota no more than once a minute.
|
||||
if ctx.quota_needs_update(session.transport_id(), 60).await
|
||||
&& let Err(err) = ctx.update_recent_quota(&mut session).await
|
||||
|
||||
@@ -303,6 +303,7 @@ impl Context {
|
||||
///
|
||||
/// This comes as an HTML from the core so that we can easily improve it
|
||||
/// and the improvement instantly reaches all UIs.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub async fn get_connectivity_html(&self) -> Result<String> {
|
||||
let mut ret = r#"<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
@@ -20,14 +20,14 @@ use crate::headerdef::HeaderDef;
|
||||
use crate::key::{DcKey, Fingerprint, load_self_public_key};
|
||||
use crate::log::LogExt as _;
|
||||
use crate::log::warn;
|
||||
use crate::message::{Message, Viewtype};
|
||||
use crate::message::{self, Message, MsgId, Viewtype};
|
||||
use crate::mimeparser::{MimeMessage, SystemMessage};
|
||||
use crate::param::Param;
|
||||
use crate::qr::check_qr;
|
||||
use crate::securejoin::bob::JoinerProgress;
|
||||
use crate::sync::Sync::*;
|
||||
use crate::tools::{create_id, time};
|
||||
use crate::{SecurejoinSource, stats};
|
||||
use crate::tools::{create_id, create_outgoing_rfc724_mid, time};
|
||||
use crate::{SecurejoinSource, mimefactory, stats};
|
||||
use crate::{SecurejoinUiPath, token};
|
||||
|
||||
mod bob;
|
||||
@@ -127,9 +127,6 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option<ChatId>) -> Resul
|
||||
None => None,
|
||||
};
|
||||
let grpid = chat.as_ref().map(|c| c.grpid.as_str());
|
||||
let sync_token = token::lookup(context, Namespace::InviteNumber, grpid)
|
||||
.await?
|
||||
.is_none();
|
||||
// Invite number is used to request the inviter key.
|
||||
let invitenumber = token::lookup_or_new(context, Namespace::InviteNumber, grpid).await?;
|
||||
|
||||
@@ -156,12 +153,10 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option<ChatId>) -> Resul
|
||||
.unwrap_or_default();
|
||||
|
||||
let qr = if let Some(chat) = chat {
|
||||
if sync_token {
|
||||
context
|
||||
.sync_qr_code_tokens(Some(chat.grpid.as_str()))
|
||||
.await?;
|
||||
context.scheduler.interrupt_smtp().await;
|
||||
}
|
||||
context
|
||||
.sync_qr_code_tokens(Some(chat.grpid.as_str()))
|
||||
.await?;
|
||||
context.scheduler.interrupt_smtp().await;
|
||||
|
||||
let chat_name = chat.get_name();
|
||||
let chat_name_shortened = shorten_name(chat_name, 25);
|
||||
@@ -178,11 +173,11 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option<ChatId>) -> Resul
|
||||
if chat.typ == Chattype::OutBroadcast {
|
||||
// For historic reansons, broadcasts currently use j instead of i for the invitenumber.
|
||||
format!(
|
||||
"https://i.delta.chat/#{fingerprint}&x={grpid}&j={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}&b={chat_name_urlencoded}",
|
||||
"https://i.delta.chat/#{fingerprint}&v=3&x={grpid}&j={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}&b={chat_name_urlencoded}",
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"https://i.delta.chat/#{fingerprint}&x={grpid}&i={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}&g={chat_name_urlencoded}",
|
||||
"https://i.delta.chat/#{fingerprint}&v=3&x={grpid}&i={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}&g={chat_name_urlencoded}",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -190,12 +185,12 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option<ChatId>) -> Resul
|
||||
let self_name_urlencoded = utf8_percent_encode(&self_name_shortened, DISALLOWED_CHARACTERS)
|
||||
.to_string()
|
||||
.replace("%20", "+");
|
||||
if sync_token {
|
||||
context.sync_qr_code_tokens(None).await?;
|
||||
context.scheduler.interrupt_smtp().await;
|
||||
}
|
||||
|
||||
context.sync_qr_code_tokens(None).await?;
|
||||
context.scheduler.interrupt_smtp().await;
|
||||
|
||||
format!(
|
||||
"https://i.delta.chat/#{fingerprint}&i={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}",
|
||||
"https://i.delta.chat/#{fingerprint}&v=3&i={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}",
|
||||
)
|
||||
};
|
||||
|
||||
@@ -309,7 +304,9 @@ async fn verify_sender_by_fingerprint(
|
||||
fingerprint: &Fingerprint,
|
||||
contact_id: ContactId,
|
||||
) -> Result<bool> {
|
||||
let contact = Contact::get_by_id(context, contact_id).await?;
|
||||
let Some(contact) = Contact::get_by_id_optional(context, contact_id).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
let is_verified = contact.fingerprint().is_some_and(|fp| &fp == fingerprint);
|
||||
if is_verified {
|
||||
mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?;
|
||||
@@ -346,12 +343,18 @@ pub(crate) enum HandshakeMessage {
|
||||
/// Step of Secure-Join protocol.
|
||||
#[derive(Debug, Display, PartialEq, Eq)]
|
||||
pub(crate) enum SecureJoinStep {
|
||||
/// vc-request or vg-request
|
||||
/// vc-request or vg-request; only used in legacy securejoin
|
||||
Request { invitenumber: String },
|
||||
|
||||
/// vc-auth-required or vg-auth-required
|
||||
/// vc-auth-required or vg-auth-required; only used in legacy securejoin
|
||||
AuthRequired,
|
||||
|
||||
/// vc-request-pubkey; only used in securejoin v3
|
||||
RequestPubkey,
|
||||
|
||||
/// vc-pubkey; only used in securejoin v3
|
||||
Pubkey,
|
||||
|
||||
/// vc-request-with-auth or vg-request-with-auth
|
||||
RequestWithAuth,
|
||||
|
||||
@@ -381,6 +384,8 @@ pub(crate) fn get_secure_join_step(mime_message: &MimeMessage) -> Option<SecureJ
|
||||
})
|
||||
} else if let Some(step) = mime_message.get_header(HeaderDef::SecureJoin) {
|
||||
match step {
|
||||
"vc-request-pubkey" => Some(SecureJoinStep::RequestPubkey),
|
||||
"vc-pubkey" => Some(SecureJoinStep::Pubkey),
|
||||
"vg-auth-required" | "vc-auth-required" => Some(SecureJoinStep::AuthRequired),
|
||||
"vg-request-with-auth" | "vc-request-with-auth" => {
|
||||
Some(SecureJoinStep::RequestWithAuth)
|
||||
@@ -410,6 +415,7 @@ pub(crate) fn get_secure_join_step(mime_message: &MimeMessage) -> Option<SecureJ
|
||||
///
|
||||
/// When `handle_securejoin_handshake()` is called, the message is not yet filed in the
|
||||
/// database; this is done by `receive_imf()` later on as needed.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub(crate) async fn handle_securejoin_handshake(
|
||||
context: &Context,
|
||||
mime_message: &mut MimeMessage,
|
||||
@@ -438,7 +444,10 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
// will improve security (completely unrelated to the securejoin protocol)
|
||||
// and is something we want to do in the future:
|
||||
// https://www.rfc-editor.org/rfc/rfc9580.html#name-surreptitious-forwarding
|
||||
if !matches!(step, SecureJoinStep::Request { .. }) {
|
||||
if !matches!(
|
||||
step,
|
||||
SecureJoinStep::Request { .. } | SecureJoinStep::RequestPubkey | SecureJoinStep::Pubkey
|
||||
) {
|
||||
let mut self_found = false;
|
||||
let self_fingerprint = load_self_public_key(context).await?.dc_fingerprint();
|
||||
for (addr, key) in &mime_message.gossiped_keys {
|
||||
@@ -504,7 +513,54 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
==== Bob - the joiner's side =====
|
||||
==== Step 4 in "Setup verified contact" protocol =====
|
||||
========================================================*/
|
||||
bob::handle_auth_required(context, mime_message).await
|
||||
bob::handle_auth_required_or_pubkey(context, mime_message).await
|
||||
}
|
||||
SecureJoinStep::RequestPubkey => {
|
||||
/*========================================================
|
||||
==== Alice - the inviter's side =====
|
||||
==== Bob requests our public key (Securejoin v3) =====
|
||||
========================================================*/
|
||||
|
||||
debug_assert!(
|
||||
mime_message.signature.is_none(),
|
||||
"RequestPubkey is not supposed to be signed"
|
||||
);
|
||||
let Some(auth) = mime_message.get_header(HeaderDef::SecureJoinAuth) else {
|
||||
warn!(
|
||||
context,
|
||||
"Ignoring {step} message because of missing auth code."
|
||||
);
|
||||
return Ok(HandshakeMessage::Ignore);
|
||||
};
|
||||
if !token::exists(context, token::Namespace::Auth, auth).await? {
|
||||
warn!(context, "Secure-join denied (bad auth).");
|
||||
return Ok(HandshakeMessage::Ignore);
|
||||
}
|
||||
|
||||
let rfc724_mid = create_outgoing_rfc724_mid();
|
||||
let addr = ContactAddress::new(&mime_message.from.addr)?;
|
||||
let attach_self_pubkey = true;
|
||||
let rendered_message = mimefactory::render_symm_encrypted_securejoin_message(
|
||||
context,
|
||||
"vc-pubkey",
|
||||
&rfc724_mid,
|
||||
attach_self_pubkey,
|
||||
auth,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let msg_id = message::insert_tombstone(context, &rfc724_mid).await?;
|
||||
insert_into_smtp(context, &rfc724_mid, &addr, rendered_message, msg_id).await?;
|
||||
context.scheduler.interrupt_smtp().await;
|
||||
|
||||
Ok(HandshakeMessage::Done)
|
||||
}
|
||||
SecureJoinStep::Pubkey => {
|
||||
/*========================================================
|
||||
==== Bob - the joiner's side =====
|
||||
==== Alice sent us her pubkey (Securejoin v3) =====
|
||||
========================================================*/
|
||||
bob::handle_auth_required_or_pubkey(context, mime_message).await
|
||||
}
|
||||
SecureJoinStep::RequestWithAuth => {
|
||||
/*==========================================================
|
||||
@@ -585,15 +641,12 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?;
|
||||
}
|
||||
contact_id.regossip_keys(context).await?;
|
||||
ContactId::scaleup_origin(context, &[contact_id], Origin::SecurejoinInvited).await?;
|
||||
// for setup-contact, make Alice's one-to-one chat with Bob visible
|
||||
// (secure-join-information are shown in the group chat)
|
||||
if grpid.is_empty() {
|
||||
ChatId::create_for_contact(context, contact_id).await?;
|
||||
}
|
||||
context.emit_event(EventType::ContactsChanged(Some(contact_id)));
|
||||
if let Some(joining_chat_id) = joining_chat_id {
|
||||
// Join group.
|
||||
chat::add_contact_to_chat_ex(context, Nosync, joining_chat_id, contact_id, true)
|
||||
.await?;
|
||||
|
||||
@@ -603,6 +656,10 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
// We don't use the membership consistency algorithm for broadcast channels,
|
||||
// so, sync the memberlist when adding a contact
|
||||
chat.sync_contacts(context).await.log_err(context).ok();
|
||||
} else {
|
||||
ContactId::scaleup_origin(context, &[contact_id], Origin::SecurejoinInvited)
|
||||
.await?;
|
||||
context.emit_event(EventType::ContactsChanged(Some(contact_id)));
|
||||
}
|
||||
|
||||
inviter_progress(context, contact_id, joining_chat_id, chat.typ)?;
|
||||
@@ -665,6 +722,24 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
}
|
||||
}
|
||||
|
||||
async fn insert_into_smtp(
|
||||
context: &Context,
|
||||
rfc724_mid: &str,
|
||||
recipient: &str,
|
||||
rendered_message: String,
|
||||
msg_id: MsgId,
|
||||
) -> Result<(), Error> {
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"INSERT INTO smtp (rfc724_mid, recipients, mime, msg_id)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
(&rfc724_mid, &recipient, &rendered_message, msg_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Observe self-sent Securejoin message.
|
||||
///
|
||||
/// In a multi-device-setup, there may be other devices that "see" the handshake messages.
|
||||
@@ -696,6 +771,8 @@ pub(crate) async fn observe_securejoin_on_other_device(
|
||||
match step {
|
||||
SecureJoinStep::Request { .. }
|
||||
| SecureJoinStep::AuthRequired
|
||||
| SecureJoinStep::RequestPubkey
|
||||
| SecureJoinStep::Pubkey
|
||||
| SecureJoinStep::Deprecated
|
||||
| SecureJoinStep::Unknown { .. } => {
|
||||
return Ok(HandshakeMessage::Ignore);
|
||||
|
||||
@@ -5,20 +5,22 @@ use anyhow::{Context as _, Result};
|
||||
use super::HandshakeMessage;
|
||||
use super::qrinvite::QrInvite;
|
||||
use crate::chat::{self, ChatId, is_contact_in_chat};
|
||||
use crate::chatlist_events;
|
||||
use crate::constants::{Blocked, Chattype};
|
||||
use crate::contact::Origin;
|
||||
use crate::contact::{Contact, Origin};
|
||||
use crate::context::Context;
|
||||
use crate::events::EventType;
|
||||
use crate::key::self_fingerprint;
|
||||
use crate::log::LogExt;
|
||||
use crate::message::{Message, MsgId, Viewtype};
|
||||
use crate::message::{self, Message, MsgId, Viewtype};
|
||||
use crate::mimeparser::{MimeMessage, SystemMessage};
|
||||
use crate::param::{Param, Params};
|
||||
use crate::securejoin::{ContactId, encrypted_and_signed, verify_sender_by_fingerprint};
|
||||
use crate::securejoin::{
|
||||
ContactId, encrypted_and_signed, insert_into_smtp, verify_sender_by_fingerprint,
|
||||
};
|
||||
use crate::stock_str;
|
||||
use crate::sync::Sync::*;
|
||||
use crate::tools::{smeared_time, time};
|
||||
use crate::tools::{create_outgoing_rfc724_mid, smeared_time, time};
|
||||
use crate::{chatlist_events, mimefactory};
|
||||
|
||||
/// Starts the securejoin protocol with the QR `invite`.
|
||||
///
|
||||
@@ -47,8 +49,14 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
|
||||
// receive_imf.
|
||||
let private_chat_id = private_chat_id(context, &invite).await?;
|
||||
|
||||
ContactId::scaleup_origin(context, &[invite.contact_id()], Origin::SecurejoinJoined).await?;
|
||||
context.emit_event(EventType::ContactsChanged(None));
|
||||
match invite {
|
||||
QrInvite::Group { .. } | QrInvite::Contact { .. } => {
|
||||
ContactId::scaleup_origin(context, &[invite.contact_id()], Origin::SecurejoinJoined)
|
||||
.await?;
|
||||
context.emit_event(EventType::ContactsChanged(None));
|
||||
}
|
||||
QrInvite::Broadcast { .. } => {}
|
||||
}
|
||||
|
||||
let has_key = context
|
||||
.sql
|
||||
@@ -213,11 +221,11 @@ LIMIT 1
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handles `vc-auth-required` and `vg-auth-required` handshake messages.
|
||||
/// Handles `vc-auth-required`, `vg-auth-required`, and `vc-pubkey` handshake messages.
|
||||
///
|
||||
/// # Bob - the joiner's side
|
||||
/// ## Step 4 in the "Setup Contact protocol"
|
||||
pub(super) async fn handle_auth_required(
|
||||
pub(super) async fn handle_auth_required_or_pubkey(
|
||||
context: &Context,
|
||||
message: &MimeMessage,
|
||||
) -> Result<HandshakeMessage> {
|
||||
@@ -299,47 +307,68 @@ pub(crate) async fn send_handshake_message(
|
||||
chat_id: ChatId,
|
||||
step: BobHandshakeMsg,
|
||||
) -> Result<()> {
|
||||
let mut msg = Message {
|
||||
viewtype: Viewtype::Text,
|
||||
text: step.body_text(invite),
|
||||
hidden: true,
|
||||
..Default::default()
|
||||
};
|
||||
msg.param.set_cmd(SystemMessage::SecurejoinMessage);
|
||||
if invite.is_v3() && matches!(step, BobHandshakeMsg::Request) {
|
||||
// Send a minimal symmetrically-encrypted vc-request-pubkey message
|
||||
let rfc724_mid = create_outgoing_rfc724_mid();
|
||||
let contact = Contact::get_by_id(context, invite.contact_id()).await?;
|
||||
let recipient = contact.get_addr();
|
||||
let attach_self_pubkey = false;
|
||||
let rendered_message = mimefactory::render_symm_encrypted_securejoin_message(
|
||||
context,
|
||||
"vc-request-pubkey",
|
||||
&rfc724_mid,
|
||||
attach_self_pubkey,
|
||||
invite.authcode(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Sends the step in Secure-Join header.
|
||||
msg.param.set(Param::Arg, step.securejoin_header(invite));
|
||||
let msg_id = message::insert_tombstone(context, &rfc724_mid).await?;
|
||||
insert_into_smtp(context, &rfc724_mid, recipient, rendered_message, msg_id).await?;
|
||||
context.scheduler.interrupt_smtp().await;
|
||||
} else {
|
||||
let mut msg = Message {
|
||||
viewtype: Viewtype::Text,
|
||||
text: step.body_text(invite),
|
||||
hidden: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match step {
|
||||
BobHandshakeMsg::Request => {
|
||||
// Sends the Secure-Join-Invitenumber header in mimefactory.rs.
|
||||
msg.param.set(Param::Arg2, invite.invitenumber());
|
||||
msg.force_plaintext();
|
||||
}
|
||||
BobHandshakeMsg::RequestWithAuth => {
|
||||
// Sends the Secure-Join-Auth header in mimefactory.rs.
|
||||
msg.param.set(Param::Arg2, invite.authcode());
|
||||
msg.param.set_int(Param::GuaranteeE2ee, 1);
|
||||
msg.param.set_cmd(SystemMessage::SecurejoinMessage);
|
||||
|
||||
// Sends our own fingerprint in the Secure-Join-Fingerprint header.
|
||||
let bob_fp = self_fingerprint(context).await?;
|
||||
msg.param.set(Param::Arg3, bob_fp);
|
||||
// Sends the step in Secure-Join header.
|
||||
msg.param.set(Param::Arg, step.securejoin_header(invite));
|
||||
|
||||
// Sends the grpid in the Secure-Join-Group header.
|
||||
//
|
||||
// `Secure-Join-Group` header is deprecated,
|
||||
// but old Delta Chat core requires that Alice receives it.
|
||||
//
|
||||
// Previous Delta Chat core also sent `Secure-Join-Group` header
|
||||
// in `vg-request` messages,
|
||||
// but it was not used on the receiver.
|
||||
if let QrInvite::Group { grpid, .. } = invite {
|
||||
msg.param.set(Param::Arg4, grpid);
|
||||
match step {
|
||||
BobHandshakeMsg::Request => {
|
||||
// Sends the Secure-Join-Invitenumber header in mimefactory.rs.
|
||||
msg.param.set(Param::Arg2, invite.invitenumber());
|
||||
msg.force_plaintext();
|
||||
}
|
||||
}
|
||||
};
|
||||
BobHandshakeMsg::RequestWithAuth => {
|
||||
// Sends the Secure-Join-Auth header in mimefactory.rs.
|
||||
msg.param.set(Param::Arg2, invite.authcode());
|
||||
msg.param.set_int(Param::GuaranteeE2ee, 1);
|
||||
|
||||
chat::send_msg(context, chat_id, &mut msg).await?;
|
||||
// Sends our own fingerprint in the Secure-Join-Fingerprint header.
|
||||
let bob_fp = self_fingerprint(context).await?;
|
||||
msg.param.set(Param::Arg3, bob_fp);
|
||||
|
||||
// Sends the grpid in the Secure-Join-Group header.
|
||||
//
|
||||
// `Secure-Join-Group` header is deprecated,
|
||||
// but old Delta Chat core requires that Alice receives it.
|
||||
//
|
||||
// Previous Delta Chat core also sent `Secure-Join-Group` header
|
||||
// in `vg-request` messages,
|
||||
// but it was not used on the receiver.
|
||||
if let QrInvite::Group { grpid, .. } = invite {
|
||||
msg.param.set(Param::Arg4, grpid);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
chat::send_msg(context, chat_id, &mut msg).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::qr::Qr;
|
||||
|
||||
/// Represents the data from a QR-code scan.
|
||||
///
|
||||
/// There are methods to conveniently access fields present in both variants.
|
||||
/// There are methods to conveniently access fields present in all three variants.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub enum QrInvite {
|
||||
Contact {
|
||||
@@ -20,6 +20,8 @@ pub enum QrInvite {
|
||||
fingerprint: Fingerprint,
|
||||
invitenumber: String,
|
||||
authcode: String,
|
||||
#[serde(default)]
|
||||
is_v3: bool,
|
||||
},
|
||||
Group {
|
||||
contact_id: ContactId,
|
||||
@@ -28,6 +30,8 @@ pub enum QrInvite {
|
||||
grpid: String,
|
||||
invitenumber: String,
|
||||
authcode: String,
|
||||
#[serde(default)]
|
||||
is_v3: bool,
|
||||
},
|
||||
Broadcast {
|
||||
contact_id: ContactId,
|
||||
@@ -36,6 +40,8 @@ pub enum QrInvite {
|
||||
grpid: String,
|
||||
invitenumber: String,
|
||||
authcode: String,
|
||||
#[serde(default)]
|
||||
is_v3: bool,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -78,6 +84,14 @@ impl QrInvite {
|
||||
| Self::Broadcast { authcode, .. } => authcode,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_v3(&self) -> bool {
|
||||
match *self {
|
||||
QrInvite::Contact { is_v3, .. } => is_v3,
|
||||
QrInvite::Group { is_v3, .. } => is_v3,
|
||||
QrInvite::Broadcast { is_v3, .. } => is_v3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Qr> for QrInvite {
|
||||
@@ -90,11 +104,13 @@ impl TryFrom<Qr> for QrInvite {
|
||||
fingerprint,
|
||||
invitenumber,
|
||||
authcode,
|
||||
is_v3,
|
||||
} => Ok(QrInvite::Contact {
|
||||
contact_id,
|
||||
fingerprint,
|
||||
invitenumber,
|
||||
authcode,
|
||||
is_v3,
|
||||
}),
|
||||
Qr::AskVerifyGroup {
|
||||
grpname,
|
||||
@@ -103,6 +119,7 @@ impl TryFrom<Qr> for QrInvite {
|
||||
fingerprint,
|
||||
invitenumber,
|
||||
authcode,
|
||||
is_v3,
|
||||
} => Ok(QrInvite::Group {
|
||||
contact_id,
|
||||
fingerprint,
|
||||
@@ -110,6 +127,7 @@ impl TryFrom<Qr> for QrInvite {
|
||||
grpid,
|
||||
invitenumber,
|
||||
authcode,
|
||||
is_v3,
|
||||
}),
|
||||
Qr::AskJoinBroadcast {
|
||||
name,
|
||||
@@ -118,6 +136,7 @@ impl TryFrom<Qr> for QrInvite {
|
||||
fingerprint,
|
||||
authcode,
|
||||
invitenumber,
|
||||
is_v3,
|
||||
} => Ok(QrInvite::Broadcast {
|
||||
name,
|
||||
grpid,
|
||||
@@ -125,6 +144,7 @@ impl TryFrom<Qr> for QrInvite {
|
||||
fingerprint,
|
||||
authcode,
|
||||
invitenumber,
|
||||
is_v3,
|
||||
}),
|
||||
_ => bail!("Unsupported QR type"),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use deltachat_contact_tools::EmailAddress;
|
||||
use regex::Regex;
|
||||
|
||||
use super::*;
|
||||
use crate::chat::{CantSendReason, add_contact_to_chat, remove_contact_from_chat};
|
||||
@@ -11,10 +12,10 @@ use crate::key::self_fingerprint;
|
||||
use crate::mimeparser::{GossipedKey, SystemMessage};
|
||||
use crate::qr::Qr;
|
||||
use crate::receive_imf::receive_imf;
|
||||
use crate::stock_str::{self, messages_e2e_encrypted};
|
||||
use crate::stock_str::{self, messages_e2ee_info_msg};
|
||||
use crate::test_utils::{
|
||||
AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, TestContext, TestContextManager,
|
||||
TimeShiftFalsePositiveNote, get_chat_msg,
|
||||
TimeShiftFalsePositiveNote, get_chat_msg, sync,
|
||||
};
|
||||
use crate::tools::SystemTime;
|
||||
|
||||
@@ -27,7 +28,7 @@ enum SetupContactCase {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_setup_contact() {
|
||||
async fn test_setup_contact_basic() {
|
||||
test_setup_contact_ex(SetupContactCase::Normal).await
|
||||
}
|
||||
|
||||
@@ -62,13 +63,13 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
|
||||
bob.set_config(Config::Displayname, Some("Bob Examplenet"))
|
||||
.await
|
||||
.unwrap();
|
||||
let alice_auto_submitted_hdr;
|
||||
let alice_auto_submitted_hdr: bool;
|
||||
match case {
|
||||
SetupContactCase::AliceIsBot => {
|
||||
alice.set_config_bool(Config::Bot, true).await.unwrap();
|
||||
alice_auto_submitted_hdr = "Auto-Submitted: auto-generated";
|
||||
alice_auto_submitted_hdr = true;
|
||||
}
|
||||
_ => alice_auto_submitted_hdr = "Auto-Submitted: auto-replied",
|
||||
_ => alice_auto_submitted_hdr = false,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
@@ -108,7 +109,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
|
||||
let mut i = 0..msg_cnt;
|
||||
let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await;
|
||||
assert!(msg.is_info());
|
||||
assert_eq!(msg.get_text(), messages_e2e_encrypted(&bob).await);
|
||||
assert_eq!(msg.get_text(), messages_e2ee_info_msg(&bob).await);
|
||||
let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await;
|
||||
assert!(msg.is_info());
|
||||
assert_eq!(msg.get_text(), stock_str::securejoin_wait(&bob).await);
|
||||
@@ -118,12 +119,15 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
|
||||
assert!(!sent.payload.contains("Bob Examplenet"));
|
||||
assert_eq!(sent.recipient(), EmailAddress::new(alice_addr).unwrap());
|
||||
let msg = alice.parse_msg(&sent).await;
|
||||
assert!(!msg.was_encrypted());
|
||||
assert_eq!(msg.get_header(HeaderDef::SecureJoin).unwrap(), "vc-request");
|
||||
assert!(msg.get_header(HeaderDef::SecureJoinInvitenumber).is_some());
|
||||
assert!(msg.signature.is_none());
|
||||
assert_eq!(
|
||||
msg.get_header(HeaderDef::SecureJoin).unwrap(),
|
||||
"vc-request-pubkey"
|
||||
);
|
||||
assert!(msg.get_header(HeaderDef::SecureJoinAuth).is_some());
|
||||
assert!(!msg.header_exists(HeaderDef::AutoSubmitted));
|
||||
|
||||
tcm.section("Step 3: Alice receives vc-request, sends vc-auth-required");
|
||||
tcm.section("Step 3: Alice receives vc-request-pubkey, sends vc-pubkey");
|
||||
alice.recv_msg_trash(&sent).await;
|
||||
assert_eq!(
|
||||
Chatlist::try_load(&alice, 0, None, None)
|
||||
@@ -134,13 +138,14 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
|
||||
);
|
||||
|
||||
let sent = alice.pop_sent_msg().await;
|
||||
assert!(sent.payload.contains(alice_auto_submitted_hdr));
|
||||
assert_eq!(sent.payload.contains("Auto-Submitted:"), false);
|
||||
assert!(!sent.payload.contains("Alice Exampleorg"));
|
||||
let msg = bob.parse_msg(&sent).await;
|
||||
assert!(msg.was_encrypted());
|
||||
assert_eq!(msg.get_header(HeaderDef::SecureJoin).unwrap(), "vc-pubkey");
|
||||
assert_eq!(
|
||||
msg.get_header(HeaderDef::SecureJoin).unwrap(),
|
||||
"vc-auth-required"
|
||||
msg.get_header(HeaderDef::AutoSubmitted),
|
||||
alice_auto_submitted_hdr.then_some("auto-generated")
|
||||
);
|
||||
|
||||
let bob_chat = bob.get_chat(&alice).await;
|
||||
@@ -170,7 +175,6 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
|
||||
|
||||
// Check Bob sent the right message.
|
||||
let sent = bob.pop_sent_msg().await;
|
||||
assert!(sent.payload.contains("Auto-Submitted: auto-replied"));
|
||||
assert!(!sent.payload.contains("Bob Examplenet"));
|
||||
let mut msg = alice.parse_msg(&sent).await;
|
||||
assert!(msg.was_encrypted());
|
||||
@@ -246,7 +250,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
|
||||
let chat = alice.get_chat(&bob).await;
|
||||
let msg = get_chat_msg(&alice, chat.get_id(), 0, 1).await;
|
||||
assert!(msg.is_info());
|
||||
let expected_text = messages_e2e_encrypted(&alice).await;
|
||||
let expected_text = messages_e2ee_info_msg(&alice).await;
|
||||
assert_eq!(msg.get_text(), expected_text);
|
||||
}
|
||||
|
||||
@@ -261,7 +265,10 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
|
||||
|
||||
// Check Alice sent the right message to Bob.
|
||||
let sent = alice.pop_sent_msg().await;
|
||||
assert!(sent.payload.contains(alice_auto_submitted_hdr));
|
||||
assert_eq!(
|
||||
sent.payload.contains("Auto-Submitted: auto-generated"),
|
||||
false
|
||||
);
|
||||
assert!(!sent.payload.contains("Alice Exampleorg"));
|
||||
let msg = bob.parse_msg(&sent).await;
|
||||
assert!(msg.was_encrypted());
|
||||
@@ -288,7 +295,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
|
||||
// The `SecurejoinWait` info message has been removed, but the e2ee notice remains.
|
||||
let msg = get_chat_msg(&bob, bob_chat.get_id(), 0, 1).await;
|
||||
assert!(msg.is_info());
|
||||
assert_eq!(msg.get_text(), messages_e2e_encrypted(&bob).await);
|
||||
assert_eq!(msg.get_text(), messages_e2ee_info_msg(&bob).await);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -421,18 +428,31 @@ async fn test_setup_contact_concurrent_calls() -> Result<()> {
|
||||
assert!(!alice_id.is_special());
|
||||
assert_eq!(chat.typ, Chattype::Single);
|
||||
assert_ne!(claire_id, alice_id);
|
||||
assert!(
|
||||
assert_eq!(
|
||||
bob.pop_sent_msg()
|
||||
.await
|
||||
.payload()
|
||||
.contains("alice@example.org")
|
||||
.contains("alice@example.org"),
|
||||
false // Alice's address must not be sent in cleartext
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_secure_join() -> Result<()> {
|
||||
async fn test_secure_join_group_legacy() -> Result<()> {
|
||||
test_secure_join_group_ex(false, false).await
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_secure_join_group_v3() -> Result<()> {
|
||||
test_secure_join_group_ex(true, false).await
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_secure_join_group_v3_without_invite() -> Result<()> {
|
||||
test_secure_join_group_ex(true, true).await
|
||||
}
|
||||
|
||||
async fn test_secure_join_group_ex(v3: bool, remove_invite: bool) -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = tcm.alice().await;
|
||||
let bob = tcm.bob().await;
|
||||
@@ -444,7 +464,8 @@ async fn test_secure_join() -> Result<()> {
|
||||
let alice_chatid = chat::create_group(&alice, "the chat").await?;
|
||||
|
||||
tcm.section("Step 1: Generate QR-code, secure-join implied by chatid");
|
||||
let qr = get_securejoin_qr(&alice, Some(alice_chatid)).await.unwrap();
|
||||
let mut qr = get_securejoin_qr(&alice, Some(alice_chatid)).await.unwrap();
|
||||
manipulate_qr(v3, remove_invite, &mut qr);
|
||||
|
||||
tcm.section("Step 2: Bob scans QR-code, sends vg-request");
|
||||
let bob_chatid = join_securejoin(&bob, &qr).await?;
|
||||
@@ -456,9 +477,20 @@ async fn test_secure_join() -> Result<()> {
|
||||
EmailAddress::new("alice@example.org").unwrap()
|
||||
);
|
||||
let msg = alice.parse_msg(&sent).await;
|
||||
assert!(!msg.was_encrypted());
|
||||
assert_eq!(msg.get_header(HeaderDef::SecureJoin).unwrap(), "vg-request");
|
||||
assert!(msg.get_header(HeaderDef::SecureJoinInvitenumber).is_some());
|
||||
assert!(msg.signature.is_none());
|
||||
assert_eq!(
|
||||
msg.get_header(HeaderDef::SecureJoin).unwrap(),
|
||||
if v3 {
|
||||
"vc-request-pubkey"
|
||||
} else {
|
||||
"vg-request"
|
||||
}
|
||||
);
|
||||
assert_eq!(msg.get_header(HeaderDef::SecureJoinAuth).is_some(), v3);
|
||||
assert_eq!(
|
||||
msg.get_header(HeaderDef::SecureJoinInvitenumber).is_some(),
|
||||
!v3
|
||||
);
|
||||
assert!(!msg.header_exists(HeaderDef::AutoSubmitted));
|
||||
|
||||
// Old Delta Chat core sent `Secure-Join-Group` header in `vg-request`,
|
||||
@@ -469,19 +501,18 @@ async fn test_secure_join() -> Result<()> {
|
||||
// is only sent in `vg-request-with-auth` for compatibility.
|
||||
assert!(!msg.header_exists(HeaderDef::SecureJoinGroup));
|
||||
|
||||
tcm.section("Step 3: Alice receives vg-request, sends vg-auth-required");
|
||||
tcm.section("Step 3: Alice receives vc-request-pubkey and sends vc-pubkey, or receives vg-request and sends vg-auth-required");
|
||||
alice.recv_msg_trash(&sent).await;
|
||||
|
||||
let sent = alice.pop_sent_msg().await;
|
||||
assert!(sent.payload.contains("Auto-Submitted: auto-replied"));
|
||||
let msg = bob.parse_msg(&sent).await;
|
||||
assert!(msg.was_encrypted());
|
||||
assert_eq!(
|
||||
msg.get_header(HeaderDef::SecureJoin).unwrap(),
|
||||
"vg-auth-required"
|
||||
if v3 { "vc-pubkey" } else { "vg-auth-required" }
|
||||
);
|
||||
|
||||
tcm.section("Step 4: Bob receives vg-auth-required, sends vg-request-with-auth");
|
||||
tcm.section("Step 4: Bob receives vc-pubkey or vg-auth-required, sends v*-request-with-auth");
|
||||
bob.recv_msg_trash(&sent).await;
|
||||
let sent = bob.pop_sent_msg().await;
|
||||
|
||||
@@ -511,7 +542,6 @@ async fn test_secure_join() -> Result<()> {
|
||||
}
|
||||
|
||||
// Check Bob sent the right handshake message.
|
||||
assert!(sent.payload.contains("Auto-Submitted: auto-replied"));
|
||||
let msg = alice.parse_msg(&sent).await;
|
||||
assert!(msg.was_encrypted());
|
||||
assert_eq!(
|
||||
@@ -575,19 +605,27 @@ async fn test_secure_join() -> Result<()> {
|
||||
{
|
||||
// Now Alice's chat with Bob should still be hidden, the verified message should
|
||||
// appear in the group chat.
|
||||
if v3 {
|
||||
assert!(
|
||||
ChatIdBlocked::lookup_by_contact(&alice, contact_bob.id)
|
||||
.await?
|
||||
.is_none()
|
||||
);
|
||||
} else {
|
||||
let chat = alice.get_chat(&bob).await;
|
||||
assert_eq!(
|
||||
chat.blocked,
|
||||
Blocked::Yes,
|
||||
"Alice's 1:1 chat with Bob is not hidden"
|
||||
);
|
||||
}
|
||||
|
||||
let chat = alice.get_chat(&bob).await;
|
||||
assert_eq!(
|
||||
chat.blocked,
|
||||
Blocked::Yes,
|
||||
"Alice's 1:1 chat with Bob is not hidden"
|
||||
);
|
||||
// There should be 2 messages in the chat:
|
||||
// - The ChatProtectionEnabled message
|
||||
// - You added member bob@example.net
|
||||
let msg = get_chat_msg(&alice, alice_chatid, 0, 2).await;
|
||||
assert!(msg.is_info());
|
||||
let expected_text = messages_e2e_encrypted(&alice).await;
|
||||
let expected_text = messages_e2ee_info_msg(&alice).await;
|
||||
assert_eq!(msg.get_text(), expected_text);
|
||||
}
|
||||
|
||||
@@ -640,6 +678,97 @@ async fn test_secure_join() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_secure_join_broadcast_legacy() -> Result<()> {
|
||||
test_secure_join_broadcast_ex(false, false).await
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_secure_join_broadcast_v3() -> Result<()> {
|
||||
test_secure_join_broadcast_ex(true, false).await
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_secure_join_broadcast_v3_without_invite() -> Result<()> {
|
||||
test_secure_join_broadcast_ex(true, true).await
|
||||
}
|
||||
|
||||
async fn test_secure_join_broadcast_ex(v3: bool, remove_invite: bool) -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let alice_chat_id = chat::create_broadcast(alice, "Channel".to_string()).await?;
|
||||
let mut qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
|
||||
manipulate_qr(v3, remove_invite, &mut qr);
|
||||
let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
|
||||
let sent = alice.send_text(alice_chat_id, "Hi channel").await;
|
||||
assert!(sent.recipients.contains("bob@example.net"));
|
||||
let rcvd = bob.recv_msg(&sent).await;
|
||||
assert_eq!(rcvd.chat_id, bob_chat_id);
|
||||
assert_eq!(rcvd.text, "Hi channel");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_setup_contact_compatibility_legacy() -> Result<()> {
|
||||
test_setup_contact_compatibility_ex(false, false).await
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_setup_contact_compatibility_v3() -> Result<()> {
|
||||
test_setup_contact_compatibility_ex(true, false).await
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_setup_contact_compatibility_v3_without_invite() -> Result<()> {
|
||||
test_setup_contact_compatibility_ex(true, true).await
|
||||
}
|
||||
|
||||
async fn test_setup_contact_compatibility_ex(v3: bool, remove_invite: bool) -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
alice.set_config(Config::Displayname, Some("Alice")).await?;
|
||||
|
||||
let mut qr = get_securejoin_qr(alice, None).await?;
|
||||
manipulate_qr(v3, remove_invite, &mut qr);
|
||||
let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
|
||||
let bob_chat = Chat::load_from_db(bob, bob_chat_id).await?;
|
||||
assert_eq!(bob_chat.name, "Alice");
|
||||
assert!(bob_chat.can_send(bob).await?);
|
||||
assert_eq!(bob_chat.typ, Chattype::Single);
|
||||
assert_eq!(bob_chat.id, bob.get_chat(alice).await.id);
|
||||
|
||||
let alice_chat = alice.get_chat(bob).await;
|
||||
assert_eq!(alice_chat.name, "bob@example.net");
|
||||
assert!(alice_chat.can_send(alice).await?);
|
||||
assert_eq!(alice_chat.typ, Chattype::Single);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn manipulate_qr(v3: bool, remove_invite: bool, qr: &mut String) {
|
||||
if remove_invite {
|
||||
// Remove the INVITENUBMER. It's not needed in Securejoin v3,
|
||||
// but still included for backwards compatibility reasons.
|
||||
// We want to be able to remove it in the future,
|
||||
// therefore we test that things work without it.
|
||||
let new_qr = Regex::new("&i=.*?&").unwrap().replace(qr, "&");
|
||||
// Broadcast channels use `j` for the INVITENUMBER
|
||||
let new_qr = Regex::new("&j=.*?&").unwrap().replace(&new_qr, "&");
|
||||
assert!(new_qr != *qr);
|
||||
*qr = new_qr.to_string();
|
||||
}
|
||||
// If `!v3`, force legacy securejoin to run by removing the &v=3 parameter.
|
||||
// If `remove_invite`, we can also remove the v=3 parameter,
|
||||
// because a QR with AUTH but no INVITE is obviously v3 QR code.
|
||||
if !v3 || remove_invite {
|
||||
let new_qr = Regex::new("&v=3").unwrap().replace(qr, "");
|
||||
assert!(new_qr != *qr);
|
||||
*qr = new_qr.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_adhoc_group_no_qr() -> Result<()> {
|
||||
let alice = TestContext::new_alice().await;
|
||||
@@ -782,7 +911,18 @@ async fn test_parallel_securejoin() -> Result<()> {
|
||||
/// Tests Bob scanning setup contact QR codes of Alice and Fiona
|
||||
/// concurrently.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_parallel_setup_contact() -> Result<()> {
|
||||
async fn test_parallel_setup_contact_basic() -> Result<()> {
|
||||
test_parallel_setup_contact(false).await
|
||||
}
|
||||
|
||||
/// Tests Bob scanning setup contact QR codes of Alice and Fiona
|
||||
/// concurrently, and then deleting the Fiona contact.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_parallel_setup_contact_bob_deletes_fiona() -> Result<()> {
|
||||
test_parallel_setup_contact(true).await
|
||||
}
|
||||
|
||||
async fn test_parallel_setup_contact(bob_deletes_fiona_contact: bool) -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
@@ -803,16 +943,25 @@ async fn test_parallel_setup_contact() -> Result<()> {
|
||||
fiona.recv_msg_trash(&sent_fiona_vc_request).await;
|
||||
let sent_fiona_vc_auth_required = fiona.pop_sent_msg().await;
|
||||
|
||||
bob.recv_msg_trash(&sent_fiona_vc_auth_required).await;
|
||||
let sent_fiona_vc_request_with_auth = bob.pop_sent_msg().await;
|
||||
|
||||
fiona.recv_msg_trash(&sent_fiona_vc_request_with_auth).await;
|
||||
let sent_fiona_vc_contact_confirm = fiona.pop_sent_msg().await;
|
||||
|
||||
bob.recv_msg_trash(&sent_fiona_vc_contact_confirm).await;
|
||||
let bob_fiona_contact_id = bob.add_or_lookup_contact_id(fiona).await;
|
||||
let bob_fiona_contact = Contact::get_by_id(bob, bob_fiona_contact_id).await.unwrap();
|
||||
assert_eq!(bob_fiona_contact.is_verified(bob).await.unwrap(), true);
|
||||
if bob_deletes_fiona_contact {
|
||||
bob.get_chat(fiona).await.id.delete(bob).await?;
|
||||
Contact::delete(bob, bob_fiona_contact_id).await?;
|
||||
|
||||
bob.recv_msg_trash(&sent_fiona_vc_auth_required).await;
|
||||
let sent = bob.pop_sent_msg_opt(Duration::ZERO).await;
|
||||
assert!(sent.is_none());
|
||||
} else {
|
||||
bob.recv_msg_trash(&sent_fiona_vc_auth_required).await;
|
||||
let sent_fiona_vc_request_with_auth = bob.pop_sent_msg().await;
|
||||
|
||||
fiona.recv_msg_trash(&sent_fiona_vc_request_with_auth).await;
|
||||
let sent_fiona_vc_contact_confirm = fiona.pop_sent_msg().await;
|
||||
|
||||
bob.recv_msg_trash(&sent_fiona_vc_contact_confirm).await;
|
||||
let bob_fiona_contact = Contact::get_by_id(bob, bob_fiona_contact_id).await.unwrap();
|
||||
assert_eq!(bob_fiona_contact.is_verified(bob).await.unwrap(), true);
|
||||
}
|
||||
|
||||
// Alice gets online and previously started SecureJoin process finishes.
|
||||
alice.recv_msg_trash(&sent_alice_vc_request).await;
|
||||
@@ -1370,3 +1519,68 @@ gU6dGXsFMe/RpRHrIAkMAaM5xkxMDRuRJDxiUdS/X+Y8
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_auth_token_is_synchronized() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice1 = &tcm.alice().await;
|
||||
let alice2 = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
bob.set_config(Config::Displayname, Some("Bob")).await?;
|
||||
|
||||
alice1.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
alice2.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
|
||||
// This creates first auth token:
|
||||
let qr1 = get_securejoin_qr(alice1, None).await?;
|
||||
|
||||
// This creates another auth token; both of them need to be synchronized
|
||||
let qr2 = get_securejoin_qr(alice1, None).await?;
|
||||
sync(alice1, alice2).await;
|
||||
|
||||
// Note that Bob will throw away the AUTH token after sending `vc-request-with-auth`.
|
||||
// Therefore, he will fail to decrypt the answer from Alice's second device,
|
||||
// which leads to a "decryption failed: missing key" message in the logs.
|
||||
// This is fine.
|
||||
tcm.exec_securejoin_qr_multi_device(bob, &[alice1, alice2], &qr2)
|
||||
.await;
|
||||
|
||||
let contacts = Contact::get_all(alice2, 0, Some("Bob")).await?;
|
||||
assert_eq!(contacts[0], alice2.add_or_lookup_contact_id(bob).await);
|
||||
assert_eq!(contacts.len(), 1);
|
||||
|
||||
let chatlist = Chatlist::try_load(alice2, 0, Some("Bob"), None).await?;
|
||||
assert_eq!(chatlist.get_chat_id(0)?, alice2.get_chat(bob).await.id);
|
||||
assert_eq!(chatlist.len(), 1);
|
||||
|
||||
for qr in [qr1, qr2] {
|
||||
let qr = check_qr(bob, &qr).await?;
|
||||
let qr = QrInvite::try_from(qr)?;
|
||||
assert!(token::exists(alice2, Namespace::InviteNumber, qr.invitenumber()).await?);
|
||||
assert!(token::exists(alice2, Namespace::Auth, qr.authcode()).await?);
|
||||
}
|
||||
|
||||
// Check that alice2 only saves the invite number once:
|
||||
let invite_count: u32 = alice2
|
||||
.sql
|
||||
.query_get_value(
|
||||
"SELECT COUNT(*) FROM tokens WHERE namespc=?;",
|
||||
(Namespace::InviteNumber,),
|
||||
)
|
||||
.await?
|
||||
.unwrap();
|
||||
assert_eq!(invite_count, 1);
|
||||
|
||||
// ...but knows two AUTH tokens:
|
||||
let auth_count: u32 = alice2
|
||||
.sql
|
||||
.query_get_value(
|
||||
"SELECT COUNT(*) FROM tokens WHERE namespc=?;",
|
||||
(Namespace::Auth,),
|
||||
)
|
||||
.await?
|
||||
.unwrap();
|
||||
assert_eq!(auth_count, 2);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::tools::IsNoneOrEmpty;
|
||||
/// This escapes a bit more than actually needed by delta (e.g. also lines as "-- footer"),
|
||||
/// but for non-delta-compatibility, that seems to be better.
|
||||
/// (to be only compatible with delta, only "[\r\n|\n]-- {0,2}[\r\n|\n]" needs to be replaced)
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub fn escape_message_footer_marks(text: &str) -> String {
|
||||
if let Some(text) = text.strip_prefix("--") {
|
||||
"-\u{200B}-".to_string() + &text.replace("\n--", "\n-\u{200B}-")
|
||||
@@ -21,6 +22,7 @@ pub fn escape_message_footer_marks(text: &str) -> String {
|
||||
/// Returns `(lines, footer_lines)` tuple;
|
||||
/// `footer_lines` is set to `Some` if the footer was actually removed from `lines`
|
||||
/// (which is equal to the input array otherwise).
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub(crate) fn remove_message_footer<'a>(
|
||||
lines: &'a [&str],
|
||||
) -> (&'a [&'a str], Option<&'a [&'a str]>) {
|
||||
@@ -175,6 +177,7 @@ fn skip_forward_header<'a>(lines: &'a [&str]) -> (&'a [&'a str], bool) {
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn remove_bottom_quote<'a>(lines: &'a [&str]) -> (&'a [&'a str], Option<String>) {
|
||||
let mut first_quoted_line = lines.len();
|
||||
let mut last_quoted_line = None;
|
||||
@@ -217,6 +220,7 @@ fn remove_bottom_quote<'a>(lines: &'a [&str]) -> (&'a [&'a str], Option<String>)
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn remove_top_quote<'a>(
|
||||
lines: &'a [&str],
|
||||
is_chat_message: bool,
|
||||
@@ -262,6 +266,7 @@ fn remove_top_quote<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
fn render_message(lines: &[&str], is_cut_at_end: bool) -> String {
|
||||
let mut ret = String::new();
|
||||
/* we write empty lines only in case and non-empty line follows */
|
||||
|
||||
@@ -136,7 +136,7 @@ async fn connection_attempt(
|
||||
Err(err) => {
|
||||
warn!(
|
||||
context,
|
||||
"Failed to connect to {host} ({resolved_addr}): {err:#}."
|
||||
"SMTP failed to connect to {host} ({resolved_addr}): {err:#}."
|
||||
);
|
||||
Err(err)
|
||||
}
|
||||
|
||||
79
src/sql.rs
79
src/sql.rs
@@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result, bail, ensure};
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use rusqlite::{Connection, OpenFlags, Row, config::DbConfig, types::ValueRef};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
@@ -25,7 +25,7 @@ use crate::net::http::http_cache_cleanup;
|
||||
use crate::net::prune_connection_history;
|
||||
use crate::param::{Param, Params};
|
||||
use crate::stock_str;
|
||||
use crate::tools::{SystemTime, Time, delete_file, time, time_elapsed};
|
||||
use crate::tools::{SystemTime, delete_file, time};
|
||||
|
||||
/// Extension to [`rusqlite::ToSql`] trait
|
||||
/// which also includes [`Send`] and [`Sync`].
|
||||
@@ -48,7 +48,7 @@ macro_rules! params_slice {
|
||||
mod migrations;
|
||||
mod pool;
|
||||
|
||||
use pool::Pool;
|
||||
use pool::{Pool, WalCheckpointStats};
|
||||
|
||||
/// A wrapper around the underlying Sqlite3 object.
|
||||
#[derive(Debug)]
|
||||
@@ -663,73 +663,30 @@ impl Sql {
|
||||
&self.config_cache
|
||||
}
|
||||
|
||||
/// Runs a checkpoint operation in TRUNCATE mode, so the WAL file is truncated to 0 bytes.
|
||||
pub(crate) async fn wal_checkpoint(context: &Context) -> Result<()> {
|
||||
let t_start = Time::now();
|
||||
let lock = context.sql.pool.read().await;
|
||||
/// Attempts to truncate the WAL file.
|
||||
pub(crate) async fn wal_checkpoint(&self, context: &Context) -> Result<()> {
|
||||
let lock = self.pool.read().await;
|
||||
let Some(pool) = lock.as_ref() else {
|
||||
// No db connections, nothing to checkpoint.
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Do as much work as possible without blocking anybody.
|
||||
let query_only = true;
|
||||
let conn = pool.get(query_only).await?;
|
||||
tokio::task::block_in_place(|| {
|
||||
// Execute some transaction causing the WAL file to be opened so that the
|
||||
// `wal_checkpoint()` can proceed, otherwise it fails when called the first time,
|
||||
// see https://sqlite.org/forum/forumpost/7512d76a05268fc8.
|
||||
conn.query_row("PRAGMA table_list", [], |_| Ok(()))?;
|
||||
conn.query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |_| Ok(()))
|
||||
})?;
|
||||
|
||||
// Kick out writers.
|
||||
const _: () = assert!(Sql::N_DB_CONNECTIONS > 1, "Deadlock possible");
|
||||
let _write_lock = pool.write_lock().await;
|
||||
let t_writers_blocked = Time::now();
|
||||
// Ensure that all readers use the most recent database snapshot (are at the end of WAL) so
|
||||
// that `wal_checkpoint(FULL)` isn't blocked. We could use `PASSIVE` as well, but it's
|
||||
// documented poorly, https://www.sqlite.org/pragma.html#pragma_wal_checkpoint and
|
||||
// https://www.sqlite.org/c3ref/wal_checkpoint_v2.html don't tell how it interacts with new
|
||||
// readers.
|
||||
let mut read_conns = Vec::with_capacity(Self::N_DB_CONNECTIONS - 1);
|
||||
for _ in 0..(Self::N_DB_CONNECTIONS - 1) {
|
||||
read_conns.push(pool.get(query_only).await?);
|
||||
}
|
||||
read_conns.clear();
|
||||
// Checkpoint the remaining WAL pages without blocking readers.
|
||||
let (pages_total, pages_checkpointed) = tokio::task::block_in_place(|| {
|
||||
conn.query_row("PRAGMA wal_checkpoint(FULL)", [], |row| {
|
||||
let pages_total: i64 = row.get(1)?;
|
||||
let pages_checkpointed: i64 = row.get(2)?;
|
||||
Ok((pages_total, pages_checkpointed))
|
||||
})
|
||||
})?;
|
||||
let WalCheckpointStats {
|
||||
total_duration,
|
||||
writers_blocked_duration,
|
||||
readers_blocked_duration,
|
||||
pages_total,
|
||||
pages_checkpointed,
|
||||
} = pool.wal_checkpoint().await?;
|
||||
if pages_checkpointed < pages_total {
|
||||
warn!(
|
||||
context,
|
||||
"Cannot checkpoint whole WAL. Pages total: {pages_total}, checkpointed: {pages_checkpointed}. Make sure there are no external connections running transactions.",
|
||||
);
|
||||
}
|
||||
// Kick out readers to avoid blocking/SQLITE_BUSY.
|
||||
for _ in 0..(Self::N_DB_CONNECTIONS - 1) {
|
||||
read_conns.push(pool.get(query_only).await?);
|
||||
}
|
||||
let t_readers_blocked = Time::now();
|
||||
tokio::task::block_in_place(|| {
|
||||
let blocked = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
|
||||
let blocked: i64 = row.get(0)?;
|
||||
Ok(blocked)
|
||||
})?;
|
||||
ensure!(blocked == 0);
|
||||
Ok(())
|
||||
})?;
|
||||
info!(
|
||||
context,
|
||||
"wal_checkpoint: Total time: {:?}. Writers blocked for: {:?}. Readers blocked for: {:?}.",
|
||||
time_elapsed(&t_start),
|
||||
time_elapsed(&t_writers_blocked),
|
||||
time_elapsed(&t_readers_blocked),
|
||||
"wal_checkpoint: Total time: {total_duration:?}. Writers blocked for: {writers_blocked_duration:?}. Readers blocked for: {readers_blocked_duration:?}."
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -798,6 +755,7 @@ fn new_connection(path: &Path, passphrase: &str) -> Result<Connection> {
|
||||
// Tries to clear the freelist to free some space on the disk.
|
||||
//
|
||||
// This only works if auto_vacuum is enabled.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn incremental_vacuum(context: &Context) -> Result<()> {
|
||||
context
|
||||
.sql
|
||||
@@ -827,6 +785,10 @@ async fn incremental_vacuum(context: &Context) -> Result<()> {
|
||||
|
||||
/// Cleanup the account to restore some storage and optimize the database.
|
||||
pub async fn housekeeping(context: &Context) -> Result<()> {
|
||||
let Ok(_housekeeping_lock) = context.housekeeping_mutex.try_lock() else {
|
||||
// Housekeeping is already running in another thread, do nothing.
|
||||
return Ok(());
|
||||
};
|
||||
// Setting `Config::LastHousekeeping` at the beginning avoids endless loops when things do not
|
||||
// work out for whatever reason or are interrupted by the OS.
|
||||
if let Err(e) = context
|
||||
@@ -881,7 +843,7 @@ pub async fn housekeeping(context: &Context) -> Result<()> {
|
||||
// bigger than 200M) and also make sure we truncate the WAL periodically. Auto-checkponting does
|
||||
// not normally truncate the WAL (unless the `journal_size_limit` pragma is set), see
|
||||
// https://www.sqlite.org/wal.html.
|
||||
if let Err(err) = Sql::wal_checkpoint(context).await {
|
||||
if let Err(err) = Sql::wal_checkpoint(&context.sql, context).await {
|
||||
warn!(context, "wal_checkpoint() failed: {err:#}.");
|
||||
debug_assert!(false);
|
||||
}
|
||||
@@ -956,6 +918,7 @@ pub fn row_get_vec(row: &Row, idx: usize) -> rusqlite::Result<Vec<u8>> {
|
||||
}
|
||||
|
||||
/// Enumerates used files in the blobdir and removes unused ones.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub async fn remove_unused_files(context: &Context) -> Result<()> {
|
||||
let mut files_in_use = HashSet::new();
|
||||
let mut unreferenced_count = 0;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,6 +51,9 @@ use anyhow::{Context, Result};
|
||||
use rusqlite::Connection;
|
||||
use tokio::sync::{Mutex, OwnedMutexGuard, OwnedSemaphorePermit, Semaphore};
|
||||
|
||||
mod wal_checkpoint;
|
||||
pub(crate) use wal_checkpoint::WalCheckpointStats;
|
||||
|
||||
/// Inner connection pool.
|
||||
#[derive(Debug)]
|
||||
struct InnerPool {
|
||||
@@ -68,6 +71,24 @@ struct InnerPool {
|
||||
/// This mutex is locked when write connection
|
||||
/// is outside the pool.
|
||||
pub(crate) write_mutex: Arc<Mutex<()>>,
|
||||
|
||||
/// WAL checkpointing mutex.
|
||||
///
|
||||
/// This mutex ensures that no more than one thread
|
||||
/// runs WAL checkpointing at the same time.
|
||||
///
|
||||
/// Normal procedures acquire either one read connection
|
||||
/// or one write connection with a write mutex,
|
||||
/// and return the resources without trying to acquire
|
||||
/// more connections or trying to acquire write mutex
|
||||
/// without returning the read connection first.
|
||||
/// WAL checkpointing is special, it tries to acquire all
|
||||
/// connections and the write mutex,
|
||||
/// so two threads doing this at the same time
|
||||
/// may result in a deadlock with one thread
|
||||
/// waiting for a write lock and the other thread
|
||||
/// waiting for a connection.
|
||||
wal_checkpoint_mutex: Mutex<()>,
|
||||
}
|
||||
|
||||
impl InnerPool {
|
||||
@@ -188,6 +209,7 @@ impl Pool {
|
||||
connections: parking_lot::Mutex::new(connections),
|
||||
semaphore,
|
||||
write_mutex: Default::default(),
|
||||
wal_checkpoint_mutex: Default::default(),
|
||||
});
|
||||
Pool { inner }
|
||||
}
|
||||
@@ -196,11 +218,8 @@ impl Pool {
|
||||
Arc::clone(&self.inner).get(query_only).await
|
||||
}
|
||||
|
||||
/// Returns a mutex guard guaranteeing that there are no concurrent write connections.
|
||||
///
|
||||
/// NB: Make sure you're not holding all connections when calling this, otherwise it deadlocks
|
||||
/// if there is a concurrent writer waiting for available connection.
|
||||
pub(crate) async fn write_lock(&self) -> OwnedMutexGuard<()> {
|
||||
Arc::clone(&self.inner.write_mutex).lock_owned().await
|
||||
/// Truncates the WAL file.
|
||||
pub(crate) async fn wal_checkpoint(&self) -> Result<WalCheckpointStats> {
|
||||
wal_checkpoint::wal_checkpoint(self).await
|
||||
}
|
||||
}
|
||||
|
||||
93
src/sql/pool/wal_checkpoint.rs
Normal file
93
src/sql/pool/wal_checkpoint.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
//! # WAL checkpointing for SQLite connection pool.
|
||||
|
||||
use anyhow::{Result, ensure};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::sql::Sql;
|
||||
use crate::tools::{Time, time_elapsed};
|
||||
|
||||
use super::Pool;
|
||||
|
||||
/// Information about WAL checkpointing call for logging.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct WalCheckpointStats {
|
||||
/// Duration of the whole WAL checkpointing.
|
||||
pub total_duration: Duration,
|
||||
|
||||
/// Duration for which WAL checkpointing blocked the writers.
|
||||
pub writers_blocked_duration: Duration,
|
||||
|
||||
/// Duration for which WAL checkpointing blocked the readers.
|
||||
pub readers_blocked_duration: Duration,
|
||||
|
||||
/// Number of pages in WAL before truncating.
|
||||
pub pages_total: i64,
|
||||
|
||||
/// Number of checkpointed WAL pages.
|
||||
///
|
||||
/// It should be the same as `pages_total`
|
||||
/// unless there are external connections to the database
|
||||
/// that are not in the pool.
|
||||
pub pages_checkpointed: i64,
|
||||
}
|
||||
|
||||
/// Runs a checkpoint operation in TRUNCATE mode, so the WAL file is truncated to 0 bytes.
|
||||
pub(super) async fn wal_checkpoint(pool: &Pool) -> Result<WalCheckpointStats> {
|
||||
let _guard = pool.inner.wal_checkpoint_mutex.lock().await;
|
||||
let t_start = Time::now();
|
||||
|
||||
// Do as much work as possible without blocking anybody.
|
||||
let query_only = true;
|
||||
let conn = pool.get(query_only).await?;
|
||||
tokio::task::block_in_place(|| {
|
||||
// Execute some transaction causing the WAL file to be opened so that the
|
||||
// `wal_checkpoint()` can proceed, otherwise it fails when called the first time,
|
||||
// see https://sqlite.org/forum/forumpost/7512d76a05268fc8.
|
||||
conn.query_row("PRAGMA table_list", [], |_| Ok(()))?;
|
||||
conn.query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |_| Ok(()))
|
||||
})?;
|
||||
|
||||
// Kick out writers.
|
||||
const _: () = assert!(Sql::N_DB_CONNECTIONS > 1, "Deadlock possible");
|
||||
let _write_lock = Arc::clone(&pool.inner.write_mutex).lock_owned().await;
|
||||
let t_writers_blocked = Time::now();
|
||||
// Ensure that all readers use the most recent database snapshot (are at the end of WAL) so
|
||||
// that `wal_checkpoint(FULL)` isn't blocked. We could use `PASSIVE` as well, but it's
|
||||
// documented poorly, https://www.sqlite.org/pragma.html#pragma_wal_checkpoint and
|
||||
// https://www.sqlite.org/c3ref/wal_checkpoint_v2.html don't tell how it interacts with new
|
||||
// readers.
|
||||
let mut read_conns = Vec::with_capacity(crate::sql::Sql::N_DB_CONNECTIONS - 1);
|
||||
for _ in 0..(crate::sql::Sql::N_DB_CONNECTIONS - 1) {
|
||||
read_conns.push(pool.get(query_only).await?);
|
||||
}
|
||||
read_conns.clear();
|
||||
// Checkpoint the remaining WAL pages without blocking readers.
|
||||
let (pages_total, pages_checkpointed) = tokio::task::block_in_place(|| {
|
||||
conn.query_row("PRAGMA wal_checkpoint(FULL)", [], |row| {
|
||||
let pages_total: i64 = row.get(1)?;
|
||||
let pages_checkpointed: i64 = row.get(2)?;
|
||||
Ok((pages_total, pages_checkpointed))
|
||||
})
|
||||
})?;
|
||||
// Kick out readers to avoid blocking/SQLITE_BUSY.
|
||||
for _ in 0..(crate::sql::Sql::N_DB_CONNECTIONS - 1) {
|
||||
read_conns.push(pool.get(query_only).await?);
|
||||
}
|
||||
let t_readers_blocked = Time::now();
|
||||
tokio::task::block_in_place(|| {
|
||||
let blocked = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
|
||||
let blocked: i64 = row.get(0)?;
|
||||
Ok(blocked)
|
||||
})?;
|
||||
ensure!(blocked == 0);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(WalCheckpointStats {
|
||||
total_duration: time_elapsed(&t_start),
|
||||
writers_blocked_duration: time_elapsed(&t_writers_blocked),
|
||||
readers_blocked_duration: time_elapsed(&t_readers_blocked),
|
||||
pages_total,
|
||||
pages_checkpointed,
|
||||
})
|
||||
}
|
||||
@@ -391,6 +391,12 @@ https://delta.chat/donate"))]
|
||||
Waiting for the device of %2$s to reply…"))]
|
||||
SecureJoinBroadcastStarted = 203,
|
||||
|
||||
#[strum(props(fallback = "Channel name changed from \"%1$s\" to \"%2$s\"."))]
|
||||
MsgBroadcastNameChanged = 204,
|
||||
|
||||
#[strum(props(fallback = "Channel image changed."))]
|
||||
MsgBroadcastImgChanged = 205,
|
||||
|
||||
#[strum(props(
|
||||
fallback = "The attachment contains anonymous usage statistics, which helps us improve Delta Chat. Thank you!"
|
||||
))]
|
||||
@@ -429,6 +435,9 @@ https://delta.chat/donate"))]
|
||||
|
||||
#[strum(props(fallback = "Chat description changed by %1$s."))]
|
||||
MsgChatDescriptionChangedBy = 241,
|
||||
|
||||
#[strum(props(fallback = "Messages are end-to-end encrypted."))]
|
||||
MessagesAreE2ee = 242,
|
||||
}
|
||||
|
||||
impl StockMessage {
|
||||
@@ -620,10 +629,10 @@ pub(crate) async fn msg_chat_description_changed(
|
||||
}
|
||||
}
|
||||
|
||||
/// Stock string: `You added member %1$s.` or `Member %1$s added by %2$s.`.
|
||||
/// Stock string: `Member %1$s added.`, `You added member %1$s.` or `Member %1$s added by %2$s.`.
|
||||
///
|
||||
/// The `added_member_addr` parameter should be an email address and is looked up in the
|
||||
/// contacts to combine with the display name.
|
||||
/// The `added_member` and `by_contact` contacts
|
||||
/// are looked up in the database to get the display names.
|
||||
pub(crate) async fn msg_add_member_local(
|
||||
context: &Context,
|
||||
added_member: ContactId,
|
||||
@@ -646,10 +655,10 @@ pub(crate) async fn msg_add_member_local(
|
||||
}
|
||||
}
|
||||
|
||||
/// Stock string: `I added member %1$s.` or `Member %1$s removed by %2$s.`.
|
||||
/// Stock string: `Member %1$s removed.` or `You removed member %1$s.` or `Member %1$s removed by %2$s.`
|
||||
///
|
||||
/// The `removed_member_addr` parameter should be an email address and is looked up in
|
||||
/// the contacts to combine with the display name.
|
||||
/// The `removed_member` and `by_contact` contacts
|
||||
/// are looked up in the database to get the display names.
|
||||
pub(crate) async fn msg_del_member_local(
|
||||
context: &Context,
|
||||
removed_member: ContactId,
|
||||
@@ -708,6 +717,19 @@ pub(crate) async fn secure_join_broadcast_started(
|
||||
}
|
||||
}
|
||||
|
||||
/// Stock string: `Channel name changed from "1%s" to "2$s".`
|
||||
pub(crate) async fn msg_broadcast_name_changed(context: &Context, from: &str, to: &str) -> String {
|
||||
translated(context, StockMessage::MsgBroadcastNameChanged)
|
||||
.await
|
||||
.replace1(from)
|
||||
.replace2(to)
|
||||
}
|
||||
|
||||
/// Stock string `Channel image changed.`
|
||||
pub(crate) async fn msg_broadcast_img_changed(context: &Context) -> String {
|
||||
translated(context, StockMessage::MsgBroadcastImgChanged).await
|
||||
}
|
||||
|
||||
/// Stock string: `You reacted %1$s to "%2$s"` or `%1$s reacted %2$s to "%3$s"`.
|
||||
pub(crate) async fn msg_reacted(
|
||||
context: &Context,
|
||||
@@ -1049,11 +1071,16 @@ pub(crate) async fn error_no_network(context: &Context) -> String {
|
||||
translated(context, StockMessage::ErrorNoNetwork).await
|
||||
}
|
||||
|
||||
/// Stock string: `Messages are end-to-end encrypted.`
|
||||
pub(crate) async fn messages_e2e_encrypted(context: &Context) -> String {
|
||||
/// Stock string: `Messages are end-to-end encrypted.`, used in info-messages, UI may add smth. as `Tap to learn more.`
|
||||
pub(crate) async fn messages_e2ee_info_msg(context: &Context) -> String {
|
||||
translated(context, StockMessage::ChatProtectionEnabled).await
|
||||
}
|
||||
|
||||
/// Stock string: `Messages are end-to-end encrypted.`
|
||||
pub(crate) async fn messages_are_e2ee(context: &Context) -> String {
|
||||
translated(context, StockMessage::MessagesAreE2ee).await
|
||||
}
|
||||
|
||||
/// Stock string: `Reply`.
|
||||
pub(crate) async fn reply_noun(context: &Context) -> String {
|
||||
translated(context, StockMessage::ReplyNoun).await
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user