mirror of
https://github.com/chatmail/core.git
synced 2026-04-02 05:22:14 +03:00
Compare commits
38 Commits
link2xt/in
...
v1.154.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffd2ec9424 | ||
|
|
498979c608 | ||
|
|
3e7b662796 | ||
|
|
6057b40910 | ||
|
|
53572fce5c | ||
|
|
53dca8ce1a | ||
|
|
29d7e0131e | ||
|
|
4ec50d1990 | ||
|
|
187274d7b7 | ||
|
|
5dc8788eab | ||
|
|
de63527d94 | ||
|
|
cb43382896 | ||
|
|
a9e177f1e7 | ||
|
|
6e8668e348 | ||
|
|
7f7c76f706 | ||
|
|
3fe9a7b17f | ||
|
|
fff4020013 | ||
|
|
4ffc0ca047 | ||
|
|
3d19996f34 | ||
|
|
7e5cec66ba | ||
|
|
a7eab13ad6 | ||
|
|
d26a27484b | ||
|
|
ed2a3a76b4 | ||
|
|
49f5523b67 | ||
|
|
548fadc84a | ||
|
|
2bce4466d7 | ||
|
|
f31e86d203 | ||
|
|
8ec098210e | ||
|
|
62e22286bb | ||
|
|
c596bfc44e | ||
|
|
379b31835b | ||
|
|
5a69d9c355 | ||
|
|
e689db4376 | ||
|
|
2d173512af | ||
|
|
adddc8e4ad | ||
|
|
8a27c3edf0 | ||
|
|
7164786165 | ||
|
|
0cfd84d803 |
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
name: Lint Rust
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: 1.83.0
|
||||
RUSTUP_TOOLCHAIN: 1.84.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -97,11 +97,11 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
rust: 1.83.0
|
||||
rust: 1.84.0
|
||||
- os: windows-latest
|
||||
rust: 1.83.0
|
||||
rust: 1.84.0
|
||||
- os: macos-latest
|
||||
rust: 1.83.0
|
||||
rust: 1.84.0
|
||||
|
||||
# Minimum Supported Rust Version = 1.77.0
|
||||
- os: ubuntu-latest
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
uses: swatinem/rust-cache@v2
|
||||
|
||||
- name: Build C library
|
||||
run: cargo build -p deltachat_ffi --features jsonrpc
|
||||
run: cargo build -p deltachat_ffi
|
||||
|
||||
- name: Upload C library
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -223,11 +223,11 @@ jobs:
|
||||
- os: macos-latest
|
||||
python: pypy3.10
|
||||
|
||||
# Minimum Supported Python Version = 3.7
|
||||
# Minimum Supported Python Version = 3.8
|
||||
# This is the minimum version for which manylinux Python wheels are
|
||||
# built. Test it with minimum supported Rust version.
|
||||
- os: ubuntu-latest
|
||||
python: 3.7
|
||||
python: 3.8
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
@@ -277,9 +277,9 @@ jobs:
|
||||
- os: macos-latest
|
||||
python: pypy3.10
|
||||
|
||||
# Minimum Supported Python Version = 3.7
|
||||
# Minimum Supported Python Version = 3.8
|
||||
- os: ubuntu-latest
|
||||
python: 3.7
|
||||
python: 3.8
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
|
||||
80
CHANGELOG.md
80
CHANGELOG.md
@@ -1,5 +1,83 @@
|
||||
# Changelog
|
||||
|
||||
## [1.154.0] - 2025-01-15
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- New group consistency algorithm.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Migration: Set bcc_self=1 if it's unset and delete_server_after!=1 ([#6432](https://github.com/deltachat/deltachat-core-rust/pull/6432)).
|
||||
- Clear the config cache after every migration ([#6438](https://github.com/deltachat/deltachat-core-rust/pull/6438)).
|
||||
|
||||
### Build system
|
||||
|
||||
- Increase minimum supported Python version to 3.8.
|
||||
- [**breaking**] Remove jsonrpc feature flag.
|
||||
|
||||
### CI
|
||||
|
||||
- Update Rust to 1.84.0.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Beta Clippy suggestions ([#6422](https://github.com/deltachat/deltachat-core-rust/pull/6422)).
|
||||
|
||||
### Refactor
|
||||
|
||||
- Use let..else.
|
||||
- Add why_cant_send_ex() capable to only ignore specified conditions.
|
||||
- Remove unnecessary is_contact_in_chat check.
|
||||
- Eliminate remaining repeat_vars() calls ([#6359](https://github.com/deltachat/deltachat-core-rust/pull/6359)).
|
||||
|
||||
### Tests
|
||||
|
||||
- Use assert_eq! to compare chatlist length.
|
||||
|
||||
## [1.153.0] - 2025-01-05
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Remove "jobs" from imap_markseen if folder doesn't exist ([#5870](https://github.com/deltachat/deltachat-core-rust/pull/5870)).
|
||||
- Delete `vg-request-with-auth` from IMAP after processing ([#6208](https://github.com/deltachat/deltachat-core-rust/pull/6208)).
|
||||
|
||||
### API-Changes
|
||||
|
||||
- Add `IncomingWebxdcNotify.chat_id` ([#6356](https://github.com/deltachat/deltachat-core-rust/pull/6356)).
|
||||
- rpc-client: Add INCOMING_REACTION to const.EventType ([#6349](https://github.com/deltachat/deltachat-core-rust/pull/6349)).
|
||||
|
||||
### Documentation
|
||||
|
||||
- Viewtype::Sticker may be changed to Image and how to disable that ([#6352](https://github.com/deltachat/deltachat-core-rust/pull/6352)).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Never change Viewtype::Sticker to Image if file has non-image extension ([#6352](https://github.com/deltachat/deltachat-core-rust/pull/6352)).
|
||||
- Change BccSelf default to 0 for chatmail ([#6340](https://github.com/deltachat/deltachat-core-rust/pull/6340)).
|
||||
- Mark holiday notice messages as bot-generated.
|
||||
- Don't treat location-only and sync messages as bot ones ([#6357](https://github.com/deltachat/deltachat-core-rust/pull/6357)).
|
||||
- Update shadowsocks crate to 1.22.0 to avoid panic when parsing some QR codes.
|
||||
- Prefer to encrypt if E2eeEnabled even if peers have EncryptPreference::NoPreference.
|
||||
- Prioritize mailing list over self-sent messages.
|
||||
- Allow empty `To` field for self-sent messages.
|
||||
- Default `to_id` to self instead of 0.
|
||||
|
||||
### Refactor
|
||||
|
||||
- Remove unused parameter and return value from `build_body_file(…)` ([#6369](https://github.com/deltachat/deltachat-core-rust/pull/6369)).
|
||||
- Deprecate Param::ErroneousE2ee.
|
||||
- Add `emit_msgs_changed_without_msg_id`.
|
||||
- Add_parts: Remove excessive `is_mdn` checks.
|
||||
- Simplify `self_sent` condition.
|
||||
- Don't ignore get_for_contact errors.
|
||||
|
||||
### Tests
|
||||
|
||||
- Messages without recipients are assigned to self chat.
|
||||
- Message with empty To: field should have a valid to_id.
|
||||
- Fix `test_logged_ac_process_ffi_failure` flakiness.
|
||||
|
||||
## [1.152.2] - 2024-12-24
|
||||
|
||||
### Features / Changes
|
||||
@@ -5541,3 +5619,5 @@ https://github.com/deltachat/deltachat-core-rust/pulls?q=is%3Apr+is%3Aclosed
|
||||
[1.152.0]: https://github.com/deltachat/deltachat-core-rust/compare/v1.151.6..v1.152.0
|
||||
[1.152.1]: https://github.com/deltachat/deltachat-core-rust/compare/v1.152.0..v1.152.1
|
||||
[1.152.2]: https://github.com/deltachat/deltachat-core-rust/compare/v1.152.1..v1.152.2
|
||||
[1.153.0]: https://github.com/deltachat/deltachat-core-rust/compare/v1.152.2..v1.153.0
|
||||
[1.154.0]: https://github.com/deltachat/deltachat-core-rust/compare/v1.153.0..v1.154.0
|
||||
|
||||
@@ -27,7 +27,7 @@ add_custom_command(
|
||||
PREFIX=${CMAKE_INSTALL_PREFIX}
|
||||
LIBDIR=${CMAKE_INSTALL_FULL_LIBDIR}
|
||||
INCLUDEDIR=${CMAKE_INSTALL_FULL_INCLUDEDIR}
|
||||
${CARGO} build --target-dir=${CMAKE_BINARY_DIR}/target --release --features jsonrpc
|
||||
${CARGO} build --target-dir=${CMAKE_BINARY_DIR}/target --release
|
||||
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/deltachat-ffi
|
||||
)
|
||||
|
||||
|
||||
172
Cargo.lock
generated
172
Cargo.lock
generated
@@ -219,7 +219,7 @@ dependencies = [
|
||||
"nom",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time 0.3.36",
|
||||
]
|
||||
|
||||
@@ -315,7 +315,7 @@ dependencies = [
|
||||
"pin-utils",
|
||||
"self_cell",
|
||||
"stop-token",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -335,7 +335,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9343dc5acf07e79ff82d0c37899f079db3534d99f189a1837c8e549c99405bec"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
@@ -363,7 +363,7 @@ dependencies = [
|
||||
"log",
|
||||
"nom",
|
||||
"pin-project",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -388,7 +388,7 @@ dependencies = [
|
||||
"crc32fast",
|
||||
"futures-lite 2.5.0",
|
||||
"pin-project",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
@@ -1306,7 +1306,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat"
|
||||
version = "1.152.2"
|
||||
version = "1.154.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-broadcast",
|
||||
@@ -1381,7 +1381,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"testdir",
|
||||
"textwrap",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-io-timeout",
|
||||
"tokio-rustls",
|
||||
@@ -1407,7 +1407,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat-jsonrpc"
|
||||
version = "1.152.2"
|
||||
version = "1.154.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel 2.3.1",
|
||||
@@ -1432,7 +1432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat-repl"
|
||||
version = "1.152.2"
|
||||
version = "1.154.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deltachat",
|
||||
@@ -1448,7 +1448,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat-rpc-server"
|
||||
version = "1.152.2"
|
||||
version = "1.154.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deltachat",
|
||||
@@ -1477,7 +1477,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat_ffi"
|
||||
version = "1.152.2"
|
||||
version = "1.154.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deltachat",
|
||||
@@ -1488,7 +1488,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"rand 0.8.5",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"yerpc",
|
||||
]
|
||||
@@ -1717,6 +1717,27 @@ version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125"
|
||||
|
||||
[[package]]
|
||||
name = "dynosaur"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92fac44672fabad44990176319b9e94393f3a38b960b5ca2af6cd90f5ecd1497"
|
||||
dependencies = [
|
||||
"dynosaur_derive",
|
||||
"trait-variant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dynosaur_derive"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16c187d1e575ef546d24f0fcd7701cc04abfe6b5e7e2758aabc450b99e835ac3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "eax"
|
||||
version = "0.5.0"
|
||||
@@ -1839,7 +1860,7 @@ dependencies = [
|
||||
"hex",
|
||||
"lazy_static",
|
||||
"regex",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2087,7 +2108,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"log",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
]
|
||||
@@ -2607,7 +2628,7 @@ dependencies = [
|
||||
"ipnet",
|
||||
"once_cell",
|
||||
"rand 0.8.5",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time 0.3.36",
|
||||
"tinyvec",
|
||||
"tokio",
|
||||
@@ -2631,7 +2652,7 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
"resolv-conf",
|
||||
"smallvec",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -3151,7 +3172,7 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"ssh-key",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"ttl_cache",
|
||||
"url",
|
||||
"zeroize",
|
||||
@@ -3281,7 +3302,7 @@ dependencies = [
|
||||
"strum",
|
||||
"stun-rs",
|
||||
"surge-ping",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time 0.3.36",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
@@ -3313,7 +3334,7 @@ dependencies = [
|
||||
"rustc-hash 2.0.0",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -3331,7 +3352,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"rustls-platform-verifier",
|
||||
"slab",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
]
|
||||
@@ -3407,7 +3428,7 @@ dependencies = [
|
||||
"combine",
|
||||
"jni-sys",
|
||||
"log",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -3627,7 +3648,7 @@ dependencies = [
|
||||
"serde_bencode",
|
||||
"serde_bytes",
|
||||
"sha1_smol",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -3798,7 +3819,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"byteorder",
|
||||
"paste",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3812,7 +3833,7 @@ dependencies = [
|
||||
"log",
|
||||
"netlink-packet-core",
|
||||
"netlink-sys",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -3850,7 +3871,7 @@ dependencies = [
|
||||
"rtnetlink",
|
||||
"serde",
|
||||
"socket2",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time 0.3.36",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -4294,7 +4315,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "560131c633294438da9f7c4b08189194b20946c8274c6b9e38881a7874dc8ee8"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"ucd-trie",
|
||||
]
|
||||
|
||||
@@ -4342,7 +4363,7 @@ dependencies = [
|
||||
"aes-gcm",
|
||||
"aes-kw",
|
||||
"argon2",
|
||||
"base64 0.22.1",
|
||||
"base64 0.21.7",
|
||||
"bitfield",
|
||||
"block-padding",
|
||||
"blowfish",
|
||||
@@ -4392,7 +4413,7 @@ dependencies = [
|
||||
"sha3",
|
||||
"signature",
|
||||
"smallvec",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"twofish",
|
||||
"x25519-dalek",
|
||||
"x448",
|
||||
@@ -4448,7 +4469,7 @@ dependencies = [
|
||||
"mainline",
|
||||
"self_cell",
|
||||
"simple-dns",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tracing",
|
||||
"ureq",
|
||||
"wasm-bindgen",
|
||||
@@ -4617,7 +4638,7 @@ dependencies = [
|
||||
"serde",
|
||||
"smallvec",
|
||||
"socket2",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time 0.3.36",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -4895,7 +4916,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash 1.1.0",
|
||||
"rustls",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -4912,7 +4933,7 @@ dependencies = [
|
||||
"rustc-hash 2.0.0",
|
||||
"rustls",
|
||||
"slab",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
]
|
||||
@@ -5116,7 +5137,7 @@ checksum = "a18479200779601e498ada4e8c1e1f50e3ee19deb0259c25825a98b5603b2cb4"
|
||||
dependencies = [
|
||||
"getrandom 0.2.12",
|
||||
"libredox",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5309,7 +5330,7 @@ dependencies = [
|
||||
"netlink-proto",
|
||||
"netlink-sys",
|
||||
"nix 0.26.4",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -5384,9 +5405,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.19"
|
||||
version = "0.23.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "934b404430bb06b3fae2cba809eb45a1ab1aecd64491213d7c3301b88393f8d1"
|
||||
checksum = "5065c3f250cbd332cd894be57c40fa52387247659b14a2d6041d121547903b1b"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
@@ -5636,9 +5657,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.215"
|
||||
version = "1.0.217"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f"
|
||||
checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
@@ -5673,9 +5694,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.215"
|
||||
version = "1.0.217"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0"
|
||||
checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5695,9 +5716,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.133"
|
||||
version = "1.0.134"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377"
|
||||
checksum = "d00f4175c42ee48b15416f6193a959ba3a0d67fc699a0db9ad12df9f83991c7d"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -5808,17 +5829,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shadowsocks"
|
||||
version = "1.21.0"
|
||||
version = "1.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ecb3780dfbc654de9383758015b9bb95c6e32fecace36ebded09d67e854d130"
|
||||
checksum = "1678a9acd37add020f89bfe05d45b9b8a6e8ad5d09f54ac2af3e0dcf0557b481"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"byte_string",
|
||||
"bytes",
|
||||
"cfg-if",
|
||||
"dynosaur",
|
||||
"futures",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -5834,18 +5855,19 @@ dependencies = [
|
||||
"shadowsocks-crypto",
|
||||
"socket2",
|
||||
"spin",
|
||||
"thiserror",
|
||||
"thiserror 2.0.9",
|
||||
"tokio",
|
||||
"tokio-tfo",
|
||||
"trait-variant",
|
||||
"url",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shadowsocks-crypto"
|
||||
version = "0.5.5"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9e49ecfad8b27f3df28848af11f08aa10df0c6b74b45748131753913be23373"
|
||||
checksum = "bc77ecb3a97509d22751b76665894fcffad2d10df8758f4e3f20c92ccde6bf4f"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -6136,7 +6158,7 @@ dependencies = [
|
||||
"pnet_packet",
|
||||
"rand 0.8.5",
|
||||
"socket2",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -6253,12 +6275,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "testdir"
|
||||
version = "0.9.1"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee79e927b64d193f5abb60d20a0eb56be0ee5a242fdeb8ce3bf054177006de52"
|
||||
checksum = "c9ffa013be124f7e8e648876190de818e3a87088ed97ccd414a398b403aec8c8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"backtrace",
|
||||
"cargo-platform",
|
||||
"cargo_metadata",
|
||||
"once_cell",
|
||||
"sysinfo",
|
||||
@@ -6282,7 +6305,16 @@ version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f072643fd0190df67a8bab670c20ef5d8737177d6ac6b2e9a236cb096206b2cc"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6296,6 +6328,17 @@ dependencies = [
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.8"
|
||||
@@ -6385,9 +6428,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.41.1"
|
||||
version = "1.42.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33"
|
||||
checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"bytes",
|
||||
@@ -6498,7 +6541,7 @@ dependencies = [
|
||||
"http 1.1.0",
|
||||
"httparse",
|
||||
"js-sys",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"wasm-bindgen",
|
||||
@@ -6645,6 +6688,17 @@ dependencies = [
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trait-variant"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
@@ -6674,7 +6728,7 @@ dependencies = [
|
||||
"log",
|
||||
"rand 0.8.5",
|
||||
"sha1",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"url",
|
||||
"utf-8",
|
||||
]
|
||||
@@ -7003,7 +7057,7 @@ dependencies = [
|
||||
"event-listener 4.0.3",
|
||||
"futures-util",
|
||||
"parking_lot",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7337,7 +7391,7 @@ dependencies = [
|
||||
"futures",
|
||||
"log",
|
||||
"serde",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"windows 0.52.0",
|
||||
]
|
||||
|
||||
@@ -7389,7 +7443,7 @@ dependencies = [
|
||||
"nom",
|
||||
"oid-registry",
|
||||
"rusticata-macros",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time 0.3.36",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat"
|
||||
version = "1.152.2"
|
||||
version = "1.154.0"
|
||||
edition = "2021"
|
||||
license = "MPL-2.0"
|
||||
rust-version = "1.77"
|
||||
@@ -86,14 +86,14 @@ regex = { workspace = true }
|
||||
rusqlite = { workspace = true, features = ["sqlcipher"] }
|
||||
rust-hsluv = "0.1"
|
||||
rustls-pki-types = "1.10.1"
|
||||
rustls = { version = "0.23.19", default-features = false }
|
||||
rustls = { version = "0.23.20", default-features = false }
|
||||
sanitize-filename = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_urlencoded = "0.7.1"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
sha-1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
shadowsocks = { version = "1.21.0", default-features = false, features = ["aead-cipher-2022"] }
|
||||
shadowsocks = { version = "1.22.0", default-features = false, features = ["aead-cipher", "aead-cipher-2022"] }
|
||||
smallvec = "1.13.2"
|
||||
strum = "0.26"
|
||||
strum_macros = "0.26"
|
||||
@@ -120,7 +120,7 @@ nu-ansi-term = { workspace = true }
|
||||
pretty_assertions = "1.4.1"
|
||||
proptest = { version = "1", default-features = false, features = ["std"] }
|
||||
tempfile = { workspace = true }
|
||||
testdir = "0.9.0"
|
||||
testdir = "0.9.3"
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
|
||||
[workspace]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat_ffi"
|
||||
version = "1.152.2"
|
||||
version = "1.154.0"
|
||||
description = "Deltachat FFI"
|
||||
edition = "2018"
|
||||
readme = "README.md"
|
||||
@@ -15,7 +15,7 @@ crate-type = ["cdylib", "staticlib"]
|
||||
|
||||
[dependencies]
|
||||
deltachat = { workspace = true, default-features = false }
|
||||
deltachat-jsonrpc = { workspace = true, optional = true }
|
||||
deltachat-jsonrpc = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
human-panic = { version = "2", default-features = false }
|
||||
num-traits = { workspace = true }
|
||||
@@ -30,5 +30,4 @@ yerpc = { workspace = true, features = ["anyhow_expose"] }
|
||||
[features]
|
||||
default = ["vendored"]
|
||||
vendored = ["deltachat/vendored", "deltachat-jsonrpc/vendored"]
|
||||
jsonrpc = ["dep:deltachat-jsonrpc"]
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ use deltachat::stock_str::StockMessage;
|
||||
use deltachat::webxdc::StatusUpdateSerial;
|
||||
use deltachat::*;
|
||||
use deltachat::{accounts::Accounts, log::LogExt};
|
||||
use deltachat_jsonrpc::api::CommandApi;
|
||||
use deltachat_jsonrpc::yerpc::{OutReceiver, RpcClient, RpcSession};
|
||||
use num_traits::{FromPrimitive, ToPrimitive};
|
||||
use once_cell::sync::Lazy;
|
||||
use rand::Rng;
|
||||
@@ -4930,105 +4932,97 @@ pub unsafe extern "C" fn dc_accounts_get_event_emitter(
|
||||
Box::into_raw(Box::new(emitter))
|
||||
}
|
||||
|
||||
#[cfg(feature = "jsonrpc")]
|
||||
mod jsonrpc {
|
||||
use deltachat_jsonrpc::api::CommandApi;
|
||||
use deltachat_jsonrpc::yerpc::{OutReceiver, RpcClient, RpcSession};
|
||||
pub struct dc_jsonrpc_instance_t {
|
||||
receiver: OutReceiver,
|
||||
handle: RpcSession<CommandApi>,
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
pub struct dc_jsonrpc_instance_t {
|
||||
receiver: OutReceiver,
|
||||
handle: RpcSession<CommandApi>,
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_jsonrpc_init(
|
||||
account_manager: *mut dc_accounts_t,
|
||||
) -> *mut dc_jsonrpc_instance_t {
|
||||
if account_manager.is_null() {
|
||||
eprintln!("ignoring careless call to dc_jsonrpc_init()");
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_jsonrpc_init(
|
||||
account_manager: *mut dc_accounts_t,
|
||||
) -> *mut dc_jsonrpc_instance_t {
|
||||
if account_manager.is_null() {
|
||||
eprintln!("ignoring careless call to dc_jsonrpc_init()");
|
||||
return ptr::null_mut();
|
||||
}
|
||||
let account_manager = &*account_manager;
|
||||
let cmd_api = block_on(deltachat_jsonrpc::api::CommandApi::from_arc(
|
||||
account_manager.inner.clone(),
|
||||
));
|
||||
|
||||
let account_manager = &*account_manager;
|
||||
let cmd_api = block_on(deltachat_jsonrpc::api::CommandApi::from_arc(
|
||||
account_manager.inner.clone(),
|
||||
));
|
||||
let (request_handle, receiver) = RpcClient::new();
|
||||
let handle = RpcSession::new(request_handle, cmd_api);
|
||||
|
||||
let (request_handle, receiver) = RpcClient::new();
|
||||
let handle = RpcSession::new(request_handle, cmd_api);
|
||||
let instance = dc_jsonrpc_instance_t { receiver, handle };
|
||||
|
||||
let instance = dc_jsonrpc_instance_t { receiver, handle };
|
||||
Box::into_raw(Box::new(instance))
|
||||
}
|
||||
|
||||
Box::into_raw(Box::new(instance))
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_jsonrpc_unref(jsonrpc_instance: *mut dc_jsonrpc_instance_t) {
|
||||
if jsonrpc_instance.is_null() {
|
||||
eprintln!("ignoring careless call to dc_jsonrpc_unref()");
|
||||
return;
|
||||
}
|
||||
drop(Box::from_raw(jsonrpc_instance));
|
||||
}
|
||||
|
||||
fn spawn_handle_jsonrpc_request(handle: RpcSession<CommandApi>, request: String) {
|
||||
spawn(async move {
|
||||
handle.handle_incoming(&request).await;
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_jsonrpc_request(
|
||||
jsonrpc_instance: *mut dc_jsonrpc_instance_t,
|
||||
request: *const libc::c_char,
|
||||
) {
|
||||
if jsonrpc_instance.is_null() || request.is_null() {
|
||||
eprintln!("ignoring careless call to dc_jsonrpc_request()");
|
||||
return;
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_jsonrpc_unref(jsonrpc_instance: *mut dc_jsonrpc_instance_t) {
|
||||
if jsonrpc_instance.is_null() {
|
||||
eprintln!("ignoring careless call to dc_jsonrpc_unref()");
|
||||
return;
|
||||
}
|
||||
drop(Box::from_raw(jsonrpc_instance));
|
||||
let handle = &(*jsonrpc_instance).handle;
|
||||
let request = to_string_lossy(request);
|
||||
spawn_handle_jsonrpc_request(handle.clone(), request);
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_jsonrpc_next_response(
|
||||
jsonrpc_instance: *mut dc_jsonrpc_instance_t,
|
||||
) -> *mut libc::c_char {
|
||||
if jsonrpc_instance.is_null() {
|
||||
eprintln!("ignoring careless call to dc_jsonrpc_next_response()");
|
||||
return ptr::null_mut();
|
||||
}
|
||||
let api = &*jsonrpc_instance;
|
||||
block_on(api.receiver.recv())
|
||||
.map(|result| serde_json::to_string(&result).unwrap_or_default().strdup())
|
||||
.unwrap_or(ptr::null_mut())
|
||||
}
|
||||
|
||||
fn spawn_handle_jsonrpc_request(handle: RpcSession<CommandApi>, request: String) {
|
||||
spawn(async move {
|
||||
handle.handle_incoming(&request).await;
|
||||
});
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_jsonrpc_blocking_call(
|
||||
jsonrpc_instance: *mut dc_jsonrpc_instance_t,
|
||||
input: *const libc::c_char,
|
||||
) -> *mut libc::c_char {
|
||||
if jsonrpc_instance.is_null() {
|
||||
eprintln!("ignoring careless call to dc_jsonrpc_blocking_call()");
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_jsonrpc_request(
|
||||
jsonrpc_instance: *mut dc_jsonrpc_instance_t,
|
||||
request: *const libc::c_char,
|
||||
) {
|
||||
if jsonrpc_instance.is_null() || request.is_null() {
|
||||
eprintln!("ignoring careless call to dc_jsonrpc_request()");
|
||||
return;
|
||||
}
|
||||
|
||||
let handle = &(*jsonrpc_instance).handle;
|
||||
let request = to_string_lossy(request);
|
||||
spawn_handle_jsonrpc_request(handle.clone(), request);
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_jsonrpc_next_response(
|
||||
jsonrpc_instance: *mut dc_jsonrpc_instance_t,
|
||||
) -> *mut libc::c_char {
|
||||
if jsonrpc_instance.is_null() {
|
||||
eprintln!("ignoring careless call to dc_jsonrpc_next_response()");
|
||||
return ptr::null_mut();
|
||||
}
|
||||
let api = &*jsonrpc_instance;
|
||||
block_on(api.receiver.recv())
|
||||
.map(|result| serde_json::to_string(&result).unwrap_or_default().strdup())
|
||||
.unwrap_or(ptr::null_mut())
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_jsonrpc_blocking_call(
|
||||
jsonrpc_instance: *mut dc_jsonrpc_instance_t,
|
||||
input: *const libc::c_char,
|
||||
) -> *mut libc::c_char {
|
||||
if jsonrpc_instance.is_null() {
|
||||
eprintln!("ignoring careless call to dc_jsonrpc_blocking_call()");
|
||||
return ptr::null_mut();
|
||||
}
|
||||
let api = &*jsonrpc_instance;
|
||||
let input = to_string_lossy(input);
|
||||
let res = block_on(api.handle.process_incoming(&input));
|
||||
match res {
|
||||
Some(message) => {
|
||||
if let Ok(message) = serde_json::to_string(&message) {
|
||||
message.strdup()
|
||||
} else {
|
||||
ptr::null_mut()
|
||||
}
|
||||
let api = &*jsonrpc_instance;
|
||||
let input = to_string_lossy(input);
|
||||
let res = block_on(api.handle.process_incoming(&input));
|
||||
match res {
|
||||
Some(message) => {
|
||||
if let Ok(message) = serde_json::to_string(&message) {
|
||||
message.strdup()
|
||||
} else {
|
||||
ptr::null_mut()
|
||||
}
|
||||
None => ptr::null_mut(),
|
||||
}
|
||||
None => ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-jsonrpc"
|
||||
version = "1.152.2"
|
||||
version = "1.154.0"
|
||||
description = "DeltaChat JSON-RPC API"
|
||||
edition = "2021"
|
||||
default-run = "deltachat-jsonrpc-server"
|
||||
|
||||
@@ -58,5 +58,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "dist/deltachat.d.ts",
|
||||
"version": "1.152.2"
|
||||
"version": "1.154.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-repl"
|
||||
version = "1.152.2"
|
||||
version = "1.154.0"
|
||||
license = "MPL-2.0"
|
||||
edition = "2021"
|
||||
repository = "https://github.com/deltachat/deltachat-core-rust"
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "deltachat-rpc-client"
|
||||
version = "1.152.2"
|
||||
version = "1.154.0"
|
||||
description = "Python client for Delta Chat core JSON-RPC interface"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -13,7 +13,6 @@ classifiers = [
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Operating System :: MacOS :: MacOS X",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
@@ -24,6 +23,7 @@ classifiers = [
|
||||
"Topic :: Communications :: Email"
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
deltachat_rpc_client = [
|
||||
|
||||
@@ -131,10 +131,7 @@ class Rpc:
|
||||
|
||||
def reader_loop(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
line = self.process.stdout.readline()
|
||||
if not line: # EOF
|
||||
break
|
||||
while line := self.process.stdout.readline():
|
||||
response = json.loads(line)
|
||||
if "id" in response:
|
||||
response_id = response["id"]
|
||||
@@ -150,10 +147,7 @@ class Rpc:
|
||||
def writer_loop(self) -> None:
|
||||
"""Writer loop ensuring only a single thread writes requests."""
|
||||
try:
|
||||
while True:
|
||||
request = self.request_queue.get()
|
||||
if not request:
|
||||
break
|
||||
while request := self.request_queue.get():
|
||||
data = (json.dumps(request) + "\n").encode()
|
||||
self.process.stdin.write(data)
|
||||
self.process.stdin.flush()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-rpc-server"
|
||||
version = "1.152.2"
|
||||
version = "1.154.0"
|
||||
description = "DeltaChat JSON-RPC server"
|
||||
edition = "2021"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "index.d.ts",
|
||||
"version": "1.152.2"
|
||||
"version": "1.154.0"
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ skip = [
|
||||
{ name = "regex-syntax", version = "0.6.29" },
|
||||
{ name = "sync_wrapper", version = "0.1.2" },
|
||||
{ name = "syn", version = "1.0.109" },
|
||||
{ name = "thiserror-impl", version = "1.0.69" },
|
||||
{ name = "thiserror", version = "1.0.69" },
|
||||
{ name = "time", version = "<0.3" },
|
||||
{ name = "wasi", version = "<0.11" },
|
||||
{ name = "windows_aarch64_gnullvm", version = "<0.52" },
|
||||
|
||||
@@ -9,7 +9,7 @@ const buildArgs = [
|
||||
'build',
|
||||
'--release',
|
||||
'--features',
|
||||
'vendored,jsonrpc',
|
||||
'vendored',
|
||||
'-p',
|
||||
'deltachat_ffi'
|
||||
]
|
||||
|
||||
@@ -55,5 +55,5 @@
|
||||
"test:mocha": "mocha node/test/test.mjs --growl --reporter=spec --bail --exit"
|
||||
},
|
||||
"types": "node/dist/index.d.ts",
|
||||
"version": "1.152.2"
|
||||
"version": "1.154.0"
|
||||
}
|
||||
|
||||
@@ -52,10 +52,7 @@ python3-venv` should give you a usable python installation.
|
||||
|
||||
First, build the core library::
|
||||
|
||||
cargo build --release -p deltachat_ffi --features jsonrpc
|
||||
|
||||
`jsonrpc` feature is required even if not used by the bindings
|
||||
because `deltachat.h` includes JSON-RPC functions unconditionally.
|
||||
cargo build --release -p deltachat_ffi
|
||||
|
||||
Create the virtual environment and activate it::
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "deltachat"
|
||||
version = "1.152.2"
|
||||
version = "1.154.0"
|
||||
description = "Python bindings for the Delta Chat Core library using CFFI against the Rust-implemented libdeltachat"
|
||||
readme = "README.rst"
|
||||
requires-python = ">=3.7"
|
||||
requires-python = ">=3.8"
|
||||
authors = [
|
||||
{ name = "holger krekel, Floris Bruynooghe, Bjoern Petersen and contributors" },
|
||||
]
|
||||
|
||||
@@ -1253,7 +1253,10 @@ def test_no_old_msg_is_fresh(acfactory, lp):
|
||||
|
||||
def test_prefer_encrypt(acfactory, lp):
|
||||
"""Test quorum rule for encryption preference in 1:1 and group chat."""
|
||||
ac1, ac2, ac3 = acfactory.get_online_accounts(3)
|
||||
ac1 = acfactory.new_online_configuring_account(fix_is_chatmail=True)
|
||||
ac2 = acfactory.new_online_configuring_account(fix_is_chatmail=True)
|
||||
ac3 = acfactory.new_online_configuring_account(fix_is_chatmail=True)
|
||||
acfactory.bring_accounts_online()
|
||||
ac1.set_config("e2ee_enabled", "0")
|
||||
ac2.set_config("e2ee_enabled", "1")
|
||||
ac3.set_config("e2ee_enabled", "0")
|
||||
@@ -1276,7 +1279,8 @@ def test_prefer_encrypt(acfactory, lp):
|
||||
lp.sec("ac2: sending message to ac1")
|
||||
chat2 = ac2.create_chat(ac1)
|
||||
msg2 = chat2.send_text("message2")
|
||||
assert not msg2.is_encrypted()
|
||||
# Own preference is `Mutual` and we have the peer's key.
|
||||
assert msg2.is_encrypted()
|
||||
ac1._evtracker.wait_next_incoming_message()
|
||||
|
||||
lp.sec("ac1: sending message to group chat with ac2 and ac3")
|
||||
@@ -1292,8 +1296,8 @@ def test_prefer_encrypt(acfactory, lp):
|
||||
ac3.set_config("e2ee_enabled", "1")
|
||||
chat3 = ac3.create_chat(ac1)
|
||||
msg4 = chat3.send_text("message4")
|
||||
# ac1 still does not prefer encryption
|
||||
assert not msg4.is_encrypted()
|
||||
# Own preference is `Mutual` and we have the peer's key.
|
||||
assert msg4.is_encrypted()
|
||||
ac1._evtracker.wait_next_incoming_message()
|
||||
|
||||
lp.sec("ac1: sending another message to group chat with ac2 and ac3")
|
||||
|
||||
@@ -212,8 +212,13 @@ def test_logged_ac_process_ffi_failure(acfactory):
|
||||
0 / 0
|
||||
|
||||
cap = Queue()
|
||||
ac1.log = cap.put
|
||||
|
||||
# Make sure the next attempt to log an event fails.
|
||||
ac1.add_account_plugin(FailPlugin())
|
||||
|
||||
# Start capturing events.
|
||||
ac1.log = cap.put
|
||||
|
||||
# cause any event eg contact added/changed
|
||||
ac1.create_contact("something@example.org")
|
||||
res = cap.get(timeout=10)
|
||||
|
||||
@@ -1 +1 @@
|
||||
2024-12-24
|
||||
2025-01-15
|
||||
@@ -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.83.0
|
||||
RUST_VERSION=1.84.0
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
test -f "/lib/libc.musl-$ARCH.so.1" && LIBC=musl || LIBC=gnu
|
||||
|
||||
@@ -11,7 +11,7 @@ set -euo pipefail
|
||||
|
||||
export DCC_RS_TARGET=debug
|
||||
export DCC_RS_DEV="$PWD"
|
||||
cargo build -p deltachat_ffi --features jsonrpc
|
||||
cargo build -p deltachat_ffi
|
||||
|
||||
tox -c python -e py --devenv venv
|
||||
venv/bin/pip install --upgrade pip
|
||||
|
||||
@@ -12,7 +12,7 @@ export DCC_RS_DEV=`pwd`
|
||||
|
||||
cd python
|
||||
|
||||
cargo build -p deltachat_ffi --features jsonrpc
|
||||
cargo build -p deltachat_ffi
|
||||
|
||||
# remove and inhibit writing PYC files
|
||||
rm -rf tests/__pycache__
|
||||
|
||||
@@ -8,7 +8,7 @@ set -e -x
|
||||
|
||||
# compile core lib
|
||||
|
||||
cargo build --release -p deltachat_ffi --features jsonrpc
|
||||
cargo build --release -p deltachat_ffi
|
||||
|
||||
# Statically link against libdeltachat.a.
|
||||
export DCC_RS_DEV="$PWD"
|
||||
@@ -31,6 +31,6 @@ unset CHATMAIL_DOMAIN
|
||||
|
||||
# Try to build wheels for a range of interpreters, but don't fail if they are not available.
|
||||
# E.g. musllinux_1_1 does not have PyPy interpreters as of 2022-07-10
|
||||
tox --workdir "$TOXWORKDIR" -e py37,py38,py39,py310,py311,py312,py313,pypy37,pypy38,pypy39,pypy310 --skip-missing-interpreters true
|
||||
tox --workdir "$TOXWORKDIR" -e py38,py39,py310,py311,py312,py313,pypy38,pypy39,pypy310 --skip-missing-interpreters true
|
||||
|
||||
auditwheel repair "$TOXWORKDIR"/wheelhouse/deltachat* -w "$TOXWORKDIR/wheelhouse"
|
||||
|
||||
444
src/chat.rs
444
src/chat.rs
@@ -3,6 +3,7 @@
|
||||
use std::cmp;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt;
|
||||
use std::marker::Sync;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
@@ -41,7 +42,6 @@ use crate::peerstate::Peerstate;
|
||||
use crate::receive_imf::ReceivedMsg;
|
||||
use crate::securejoin::BobState;
|
||||
use crate::smtp::send_msg_to_smtp;
|
||||
use crate::sql;
|
||||
use crate::stock_str;
|
||||
use crate::sync::{self, Sync::*, SyncData};
|
||||
use crate::tools::{
|
||||
@@ -364,8 +364,8 @@ impl ChatId {
|
||||
.sql
|
||||
.execute(
|
||||
"UPDATE contacts
|
||||
SET selfavatar_sent=?
|
||||
WHERE id IN(SELECT contact_id FROM chats_contacts WHERE chat_id=?);",
|
||||
SET selfavatar_sent=?
|
||||
WHERE id IN(SELECT contact_id FROM chats_contacts WHERE chat_id=? AND add_timestamp >= remove_timestamp)",
|
||||
(timestamp, self),
|
||||
)
|
||||
.await?;
|
||||
@@ -1080,6 +1080,8 @@ impl ChatId {
|
||||
JOIN chats_contacts as y
|
||||
WHERE x.contact_id > 9
|
||||
AND y.contact_id > 9
|
||||
AND x.add_timestamp >= x.remove_timestamp
|
||||
AND y.add_timestamp >= y.remove_timestamp
|
||||
AND x.chat_id=?
|
||||
AND y.chat_id<>x.chat_id
|
||||
AND y.chat_id>?
|
||||
@@ -1104,6 +1106,7 @@ impl ChatId {
|
||||
"SELECT chat_id, count(*) AS n
|
||||
FROM chats_contacts
|
||||
WHERE contact_id > ? AND chat_id > ?
|
||||
AND add_timestamp >= remove_timestamp
|
||||
GROUP BY chat_id",
|
||||
(ContactId::LAST_SPECIAL, DC_CHAT_ID_LAST_SPECIAL),
|
||||
|row| {
|
||||
@@ -1218,15 +1221,6 @@ impl ChatId {
|
||||
Ok(self.get_param(context).await?.exists(Param::Devicetalk))
|
||||
}
|
||||
|
||||
/// Returns chat member list timestamp.
|
||||
pub(crate) async fn get_member_list_timestamp(self, context: &Context) -> Result<i64> {
|
||||
Ok(self
|
||||
.get_param(context)
|
||||
.await?
|
||||
.get_i64(Param::MemberListTimestamp)
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn parent_query<T, F>(
|
||||
self,
|
||||
context: &Context,
|
||||
@@ -1652,31 +1646,63 @@ impl Chat {
|
||||
///
|
||||
/// Otherwise returns a reason useful for logging.
|
||||
pub(crate) async fn why_cant_send(&self, context: &Context) -> Result<Option<CantSendReason>> {
|
||||
use CantSendReason::*;
|
||||
self.why_cant_send_ex(context, &|_| false).await
|
||||
}
|
||||
|
||||
pub(crate) async fn why_cant_send_ex(
|
||||
&self,
|
||||
context: &Context,
|
||||
skip_fn: &(dyn Send + Sync + Fn(&CantSendReason) -> bool),
|
||||
) -> Result<Option<CantSendReason>> {
|
||||
use CantSendReason::*;
|
||||
// NB: Don't forget to update Chatlist::try_load() when changing this function!
|
||||
let reason = if self.id.is_special() {
|
||||
Some(SpecialChat)
|
||||
} else if self.is_device_talk() {
|
||||
Some(DeviceChat)
|
||||
} else if self.is_contact_request() {
|
||||
Some(ContactRequest)
|
||||
} else if self.is_protection_broken() {
|
||||
Some(ProtectionBroken)
|
||||
} else if self.is_mailing_list() && self.get_mailinglist_addr().is_none_or_empty() {
|
||||
Some(ReadOnlyMailingList)
|
||||
} else if !self.is_self_in_chat(context).await? {
|
||||
Some(NotAMember)
|
||||
} else if self
|
||||
.check_securejoin_wait(context, constants::SECUREJOIN_WAIT_TIMEOUT)
|
||||
.await?
|
||||
> 0
|
||||
|
||||
if self.id.is_special() {
|
||||
let reason = SpecialChat;
|
||||
if !skip_fn(&reason) {
|
||||
return Ok(Some(reason));
|
||||
}
|
||||
}
|
||||
if self.is_device_talk() {
|
||||
let reason = DeviceChat;
|
||||
if !skip_fn(&reason) {
|
||||
return Ok(Some(reason));
|
||||
}
|
||||
}
|
||||
if self.is_contact_request() {
|
||||
let reason = ContactRequest;
|
||||
if !skip_fn(&reason) {
|
||||
return Ok(Some(reason));
|
||||
}
|
||||
}
|
||||
if self.is_protection_broken() {
|
||||
let reason = ProtectionBroken;
|
||||
if !skip_fn(&reason) {
|
||||
return Ok(Some(reason));
|
||||
}
|
||||
}
|
||||
if self.is_mailing_list() && self.get_mailinglist_addr().is_none_or_empty() {
|
||||
let reason = ReadOnlyMailingList;
|
||||
if !skip_fn(&reason) {
|
||||
return Ok(Some(reason));
|
||||
}
|
||||
}
|
||||
|
||||
// Do potentially slow checks last and after calls to `skip_fn` which should be fast.
|
||||
let reason = NotAMember;
|
||||
if !skip_fn(&reason) && !self.is_self_in_chat(context).await? {
|
||||
return Ok(Some(reason));
|
||||
}
|
||||
let reason = SecurejoinWait;
|
||||
if !skip_fn(&reason)
|
||||
&& self
|
||||
.check_securejoin_wait(context, constants::SECUREJOIN_WAIT_TIMEOUT)
|
||||
.await?
|
||||
> 0
|
||||
{
|
||||
Some(SecurejoinWait)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(reason)
|
||||
return Ok(Some(reason));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Returns true if can send to the chat.
|
||||
@@ -1857,7 +1883,6 @@ impl Chat {
|
||||
profile_image: self
|
||||
.get_profile_image(context)
|
||||
.await?
|
||||
.map(Into::into)
|
||||
.unwrap_or_else(std::path::PathBuf::new),
|
||||
draft,
|
||||
is_muted: self.is_muted(),
|
||||
@@ -2232,7 +2257,7 @@ impl Chat {
|
||||
"SELECT c.addr \
|
||||
FROM contacts c INNER JOIN chats_contacts cc \
|
||||
ON c.id=cc.contact_id \
|
||||
WHERE cc.chat_id=?",
|
||||
WHERE cc.chat_id=? AND cc.add_timestamp >= cc.remove_timestamp",
|
||||
(self.id,),
|
||||
|row| row.get::<_, String>(0),
|
||||
|addrs| addrs.collect::<Result<Vec<_>, _>>().map_err(Into::into),
|
||||
@@ -2571,7 +2596,6 @@ impl ChatIdBlocked {
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Returns the chat for the 1:1 chat with this contact.
|
||||
@@ -2780,7 +2804,9 @@ pub async fn is_contact_in_chat(
|
||||
let exists = context
|
||||
.sql
|
||||
.exists(
|
||||
"SELECT COUNT(*) FROM chats_contacts WHERE chat_id=? AND contact_id=?;",
|
||||
"SELECT COUNT(*) FROM chats_contacts
|
||||
WHERE chat_id=? AND contact_id=?
|
||||
AND add_timestamp >= remove_timestamp",
|
||||
(chat_id, contact_id),
|
||||
)
|
||||
.await?;
|
||||
@@ -2854,20 +2880,22 @@ async fn prepare_send_msg(
|
||||
) -> Result<Vec<i64>> {
|
||||
let mut chat = Chat::load_from_db(context, chat_id).await?;
|
||||
|
||||
// Check if the chat can be sent to.
|
||||
if let Some(reason) = chat.why_cant_send(context).await? {
|
||||
if matches!(
|
||||
reason,
|
||||
CantSendReason::ProtectionBroken
|
||||
| CantSendReason::ContactRequest
|
||||
| CantSendReason::SecurejoinWait
|
||||
) && msg.param.get_cmd() == SystemMessage::SecurejoinMessage
|
||||
{
|
||||
// Send out the message, the securejoin message is supposed to repair the verification.
|
||||
let skip_fn = |reason: &CantSendReason| match reason {
|
||||
CantSendReason::ProtectionBroken
|
||||
| CantSendReason::ContactRequest
|
||||
| CantSendReason::SecurejoinWait => {
|
||||
// Allow securejoin messages, they are supposed to repair the verification.
|
||||
// If the chat is a contact request, let the user accept it later.
|
||||
} else {
|
||||
bail!("cannot send to {chat_id}: {reason}");
|
||||
msg.param.get_cmd() == SystemMessage::SecurejoinMessage
|
||||
}
|
||||
// Allow to send "Member removed" messages so we can leave the group.
|
||||
// Necessary checks should be made anyway before removing contact
|
||||
// from the chat.
|
||||
CantSendReason::NotAMember => msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup,
|
||||
_ => false,
|
||||
};
|
||||
if let Some(reason) = chat.why_cant_send_ex(context, &skip_fn).await? {
|
||||
bail!("Cannot send to {chat_id}: {reason}");
|
||||
}
|
||||
|
||||
// Check a quote reply is not leaking data from other chats.
|
||||
@@ -2996,18 +3024,6 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
|
||||
msg.chat_id.set_gossiped_timestamp(context, now).await?;
|
||||
}
|
||||
|
||||
if msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup {
|
||||
// Reject member list synchronisation from older messages. See also
|
||||
// `receive_imf::apply_group_changes()`.
|
||||
msg.chat_id
|
||||
.update_timestamp(
|
||||
context,
|
||||
Param::MemberListTimestamp,
|
||||
now.saturating_add(constants::TIMESTAMP_SENT_TOLERANCE),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if rendered_msg.last_added_location_id.is_some() {
|
||||
if let Err(err) = location::set_kml_sent_timestamp(context, msg.chat_id, now).await {
|
||||
error!(context, "Failed to set kml sent_timestamp: {err:#}.");
|
||||
@@ -3435,7 +3451,7 @@ pub async fn get_chat_contacts(context: &Context, chat_id: ChatId) -> Result<Vec
|
||||
FROM chats_contacts cc
|
||||
LEFT JOIN contacts c
|
||||
ON c.id=cc.contact_id
|
||||
WHERE cc.chat_id=?
|
||||
WHERE cc.chat_id=? AND cc.add_timestamp >= cc.remove_timestamp
|
||||
ORDER BY c.id=1, c.last_seen DESC, c.id DESC;",
|
||||
(chat_id,),
|
||||
|row| row.get::<_, ContactId>(0),
|
||||
@@ -3446,6 +3462,26 @@ pub async fn get_chat_contacts(context: &Context, chat_id: ChatId) -> Result<Vec
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Returns a vector of contact IDs for given chat ID that are no longer part of the group.
|
||||
pub async fn get_past_chat_contacts(context: &Context, chat_id: ChatId) -> Result<Vec<ContactId>> {
|
||||
let list = context
|
||||
.sql
|
||||
.query_map(
|
||||
"SELECT cc.contact_id
|
||||
FROM chats_contacts cc
|
||||
LEFT JOIN contacts c
|
||||
ON c.id=cc.contact_id
|
||||
WHERE cc.chat_id=? AND cc.add_timestamp < cc.remove_timestamp
|
||||
ORDER BY c.id=1, c.last_seen DESC, c.id DESC",
|
||||
(chat_id,),
|
||||
|row| row.get::<_, ContactId>(0),
|
||||
|ids| ids.collect::<Result<Vec<_>, _>>().map_err(Into::into),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Creates a group chat with a given `name`.
|
||||
pub async fn create_group_chat(
|
||||
context: &Context,
|
||||
@@ -3469,9 +3505,7 @@ pub async fn create_group_chat(
|
||||
.await?;
|
||||
|
||||
let chat_id = ChatId::new(u32::try_from(row_id)?);
|
||||
if !is_contact_in_chat(context, chat_id, ContactId::SELF).await? {
|
||||
add_to_chat_contacts_table(context, chat_id, &[ContactId::SELF]).await?;
|
||||
}
|
||||
add_to_chat_contacts_table(context, timestamp, chat_id, &[ContactId::SELF]).await?;
|
||||
|
||||
context.emit_msgs_changed_without_ids();
|
||||
chatlist_events::emit_chatlist_changed(context);
|
||||
@@ -3577,18 +3611,37 @@ pub(crate) async fn create_broadcast_list_ex(
|
||||
/// Set chat contacts in the `chats_contacts` table.
|
||||
pub(crate) async fn update_chat_contacts_table(
|
||||
context: &Context,
|
||||
timestamp: i64,
|
||||
id: ChatId,
|
||||
contacts: &HashSet<ContactId>,
|
||||
) -> Result<()> {
|
||||
context
|
||||
.sql
|
||||
.transaction(move |transaction| {
|
||||
transaction.execute("DELETE FROM chats_contacts WHERE chat_id=?", (id,))?;
|
||||
for contact_id in contacts {
|
||||
transaction.execute(
|
||||
"INSERT INTO chats_contacts (chat_id, contact_id) VALUES(?, ?)",
|
||||
(id, contact_id),
|
||||
// Bump `remove_timestamp` to at least `now`
|
||||
// even for members from `contacts`.
|
||||
// We add members from `contacts` back below.
|
||||
transaction.execute(
|
||||
"UPDATE chats_contacts
|
||||
SET remove_timestamp=MAX(add_timestamp+1, ?)
|
||||
WHERE chat_id=?",
|
||||
(timestamp, id),
|
||||
)?;
|
||||
|
||||
if !contacts.is_empty() {
|
||||
let mut statement = transaction.prepare(
|
||||
"INSERT INTO chats_contacts (chat_id, contact_id, add_timestamp)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT (chat_id, contact_id)
|
||||
DO UPDATE SET add_timestamp=remove_timestamp",
|
||||
)?;
|
||||
|
||||
for contact_id in contacts {
|
||||
// We bumped `add_timestamp` for existing rows above,
|
||||
// so on conflict it is enough to set `add_timestamp = remove_timestamp`
|
||||
// and this guarantees that `add_timestamp` is no less than `timestamp`.
|
||||
statement.execute((id, contact_id, timestamp))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
@@ -3599,17 +3652,21 @@ pub(crate) async fn update_chat_contacts_table(
|
||||
/// Adds contacts to the `chats_contacts` table.
|
||||
pub(crate) async fn add_to_chat_contacts_table(
|
||||
context: &Context,
|
||||
timestamp: i64,
|
||||
chat_id: ChatId,
|
||||
contact_ids: &[ContactId],
|
||||
) -> Result<()> {
|
||||
context
|
||||
.sql
|
||||
.transaction(move |transaction| {
|
||||
let mut add_statement = transaction.prepare(
|
||||
"INSERT INTO chats_contacts (chat_id, contact_id, add_timestamp) VALUES(?1, ?2, ?3)
|
||||
ON CONFLICT (chat_id, contact_id)
|
||||
DO UPDATE SET add_timestamp=MAX(remove_timestamp, ?3)",
|
||||
)?;
|
||||
|
||||
for contact_id in contact_ids {
|
||||
transaction.execute(
|
||||
"INSERT OR IGNORE INTO chats_contacts (chat_id, contact_id) VALUES(?, ?)",
|
||||
(chat_id, contact_id),
|
||||
)?;
|
||||
add_statement.execute((chat_id, contact_id, timestamp))?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
@@ -3618,17 +3675,21 @@ pub(crate) async fn add_to_chat_contacts_table(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// remove a contact from the chats_contact table
|
||||
/// Removes a contact from the chat
|
||||
/// by updating the `remove_timestamp`.
|
||||
pub(crate) async fn remove_from_chat_contacts_table(
|
||||
context: &Context,
|
||||
chat_id: ChatId,
|
||||
contact_id: ContactId,
|
||||
) -> Result<()> {
|
||||
let now = time();
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"DELETE FROM chats_contacts WHERE chat_id=? AND contact_id=?",
|
||||
(chat_id, contact_id),
|
||||
"UPDATE chats_contacts
|
||||
SET remove_timestamp=MAX(add_timestamp+1, ?)
|
||||
WHERE chat_id=? AND contact_id=?",
|
||||
(now, chat_id, contact_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -3718,7 +3779,7 @@ pub(crate) async fn add_contact_to_chat_ex(
|
||||
if is_contact_in_chat(context, chat_id, contact_id).await? {
|
||||
return Ok(false);
|
||||
}
|
||||
add_to_chat_contacts_table(context, chat_id, &[contact_id]).await?;
|
||||
add_to_chat_contacts_table(context, time(), chat_id, &[contact_id]).await?;
|
||||
}
|
||||
if chat.typ == Chattype::Group && chat.is_promoted() {
|
||||
msg.viewtype = Viewtype::Text;
|
||||
@@ -3728,10 +3789,8 @@ pub(crate) async fn add_contact_to_chat_ex(
|
||||
msg.param.set_cmd(SystemMessage::MemberAddedToGroup);
|
||||
msg.param.set(Param::Arg, contact_addr);
|
||||
msg.param.set_int(Param::Arg2, from_handshake.into());
|
||||
if let Err(e) = send_msg(context, chat_id, &mut msg).await {
|
||||
remove_from_chat_contacts_table(context, chat_id, contact_id).await?;
|
||||
return Err(e);
|
||||
}
|
||||
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
|
||||
@@ -3766,9 +3825,9 @@ pub(crate) async fn shall_attach_selfavatar(context: &Context, chat_id: ChatId)
|
||||
.sql
|
||||
.query_map(
|
||||
"SELECT c.selfavatar_sent
|
||||
FROM chats_contacts cc
|
||||
LEFT JOIN contacts c ON c.id=cc.contact_id
|
||||
WHERE cc.chat_id=? AND cc.contact_id!=?;",
|
||||
FROM chats_contacts cc
|
||||
LEFT JOIN contacts c ON c.id=cc.contact_id
|
||||
WHERE cc.chat_id=? AND cc.contact_id!=? AND cc.add_timestamp >= cc.remove_timestamp",
|
||||
(chat_id, ContactId::SELF),
|
||||
|row| Ok(row.get::<_, i64>(0)),
|
||||
|rows| {
|
||||
@@ -3895,6 +3954,9 @@ pub async fn remove_contact_from_chat(
|
||||
bail!("{}", err_msg);
|
||||
} else {
|
||||
let mut sync = Nosync;
|
||||
|
||||
remove_from_chat_contacts_table(context, chat_id, contact_id).await?;
|
||||
|
||||
// We do not return an error if the contact does not exist in the database.
|
||||
// This allows to delete dangling references to deleted contacts
|
||||
// in case of the database becoming inconsistent due to a bug.
|
||||
@@ -3924,18 +3986,6 @@ pub async fn remove_contact_from_chat(
|
||||
sync = Sync;
|
||||
}
|
||||
}
|
||||
// we remove the member from the chat after constructing the
|
||||
// to-be-send message. If between send_msg() and here the
|
||||
// process dies, the user will be able to redo the action. It's better than the other
|
||||
// way round: you removed someone from DB but no peer or device gets to know about it
|
||||
// and group membership is thus different on different devices. But if send_msg()
|
||||
// failed, we still remove the member locally, otherwise it would be impossible to
|
||||
// remove a member with missing key from a protected group.
|
||||
// Note also that sending a message needs all recipients
|
||||
// in order to correctly determine encryption so if we
|
||||
// removed it first, it would complicate the
|
||||
// check/encryption logic.
|
||||
remove_from_chat_contacts_table(context, chat_id, contact_id).await?;
|
||||
context.emit_event(EventType::ChatModified(chat_id));
|
||||
if sync.into() {
|
||||
chat.sync_contacts(context).await.log_err(context).ok();
|
||||
@@ -4093,7 +4143,6 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId)
|
||||
ensure!(!msg_ids.is_empty(), "empty msgs_ids: nothing to forward");
|
||||
ensure!(!chat_id.is_special(), "can not forward to special chat");
|
||||
|
||||
let mut created_chats: Vec<ChatId> = Vec::new();
|
||||
let mut created_msgs: Vec<MsgId> = Vec::new();
|
||||
let mut curr_timestamp: i64;
|
||||
|
||||
@@ -4105,20 +4154,17 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId)
|
||||
bail!("cannot send to {}: {}", chat_id, reason);
|
||||
}
|
||||
curr_timestamp = create_smeared_timestamps(context, msg_ids.len());
|
||||
let ids = context
|
||||
.sql
|
||||
.query_map(
|
||||
&format!(
|
||||
"SELECT id FROM msgs WHERE id IN({}) ORDER BY timestamp,id",
|
||||
sql::repeat_vars(msg_ids.len())
|
||||
),
|
||||
rusqlite::params_from_iter(msg_ids),
|
||||
|row| row.get::<_, MsgId>(0),
|
||||
|ids| ids.collect::<Result<Vec<_>, _>>().map_err(Into::into),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for id in ids {
|
||||
let mut msgs = Vec::with_capacity(msg_ids.len());
|
||||
for id in msg_ids {
|
||||
let ts: i64 = context
|
||||
.sql
|
||||
.query_get_value("SELECT timestamp FROM msgs WHERE id=?", (id,))
|
||||
.await?
|
||||
.context("No message {id}")?;
|
||||
msgs.push((ts, *id));
|
||||
}
|
||||
msgs.sort_unstable();
|
||||
for (_, id) in msgs {
|
||||
let src_msg_id: MsgId = id;
|
||||
let mut msg = Message::load_from_db(context, src_msg_id).await?;
|
||||
if msg.state == MessageState::OutDraft {
|
||||
@@ -4155,11 +4201,10 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId)
|
||||
if !create_send_msg_jobs(context, &mut msg).await?.is_empty() {
|
||||
context.scheduler.interrupt_smtp().await;
|
||||
}
|
||||
created_chats.push(chat_id);
|
||||
created_msgs.push(new_msg_id);
|
||||
}
|
||||
for (chat_id, msg_id) in created_chats.iter().zip(created_msgs.iter()) {
|
||||
context.emit_msgs_changed(*chat_id, *msg_id);
|
||||
for msg_id in created_msgs {
|
||||
context.emit_msgs_changed(chat_id, msg_id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -4555,7 +4600,7 @@ async fn set_contacts_by_addrs(context: &Context, id: ChatId, addrs: &[String])
|
||||
if contacts == contacts_old {
|
||||
return Ok(());
|
||||
}
|
||||
update_chat_contacts_table(context, id, &contacts).await?;
|
||||
update_chat_contacts_table(context, time(), id, &contacts).await?;
|
||||
context.emit_event(EventType::ChatModified(id));
|
||||
Ok(())
|
||||
}
|
||||
@@ -5063,11 +5108,11 @@ mod tests {
|
||||
bob.recv_msg(&alice_sent_add_msg).await;
|
||||
|
||||
SystemTime::shift(Duration::from_secs(3600));
|
||||
// This adds Bob because they left quite long ago.
|
||||
|
||||
// Alice sends a message to Bob because the message about leaving is lost.
|
||||
let alice_sent_msg = alice.send_text(alice_chat_id, "What a silence!").await;
|
||||
bob.recv_msg(&alice_sent_msg).await;
|
||||
|
||||
// Test that add message is rewritten.
|
||||
bob.golden_test_chat(bob_chat_id, "chat_test_parallel_member_remove")
|
||||
.await;
|
||||
|
||||
@@ -5087,9 +5132,9 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that if a message implicitly adds a member, both messages appear.
|
||||
/// Test that member removal is synchronized eventually even if the message is lost.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_msg_with_implicit_member_add() -> Result<()> {
|
||||
async fn test_msg_with_implicit_member_removed() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = tcm.alice().await;
|
||||
let bob = tcm.bob().await;
|
||||
@@ -5105,22 +5150,35 @@ mod tests {
|
||||
let bob_received_msg = bob.recv_msg(&sent_msg).await;
|
||||
let bob_chat_id = bob_received_msg.get_chat_id();
|
||||
bob_chat_id.accept(&bob).await?;
|
||||
assert_eq!(get_chat_contacts(&bob, bob_chat_id).await?.len(), 2);
|
||||
|
||||
add_contact_to_chat(&alice, alice_chat_id, alice_fiona_contact_id).await?;
|
||||
let sent_msg = alice.pop_sent_msg().await;
|
||||
bob.recv_msg(&sent_msg).await;
|
||||
|
||||
// Bob removed Fiona, but the message is lost.
|
||||
remove_contact_from_chat(&bob, bob_chat_id, bob_fiona_contact_id).await?;
|
||||
bob.pop_sent_msg().await;
|
||||
|
||||
// This doesn't add Fiona back because Bob just removed them.
|
||||
let sent_msg = alice.send_text(alice_chat_id, "Welcome, Fiona!").await;
|
||||
bob.recv_msg(&sent_msg).await;
|
||||
assert_eq!(get_chat_contacts(&bob, bob_chat_id).await?.len(), 2);
|
||||
|
||||
// Even after some time Fiona is not added back.
|
||||
SystemTime::shift(Duration::from_secs(3600));
|
||||
let sent_msg = alice.send_text(alice_chat_id, "Welcome back, Fiona!").await;
|
||||
bob.recv_msg(&sent_msg).await;
|
||||
bob.golden_test_chat(bob_chat_id, "chat_test_msg_with_implicit_member_add")
|
||||
assert_eq!(get_chat_contacts(&bob, bob_chat_id).await?.len(), 2);
|
||||
|
||||
// If Bob sends a message to Alice now, Fiona is removed.
|
||||
assert_eq!(get_chat_contacts(&alice, alice_chat_id).await?.len(), 3);
|
||||
let sent_msg = bob
|
||||
.send_text(alice_chat_id, "I have removed Fiona some time ago.")
|
||||
.await;
|
||||
alice.recv_msg(&sent_msg).await;
|
||||
assert_eq!(get_chat_contacts(&alice, alice_chat_id).await?.len(), 2);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -5163,6 +5221,8 @@ mod tests {
|
||||
assert_eq!(a2_msg.get_info_type(), SystemMessage::MemberAddedToGroup);
|
||||
assert_eq!(get_chat_contacts(&a1, a1_chat_id).await?.len(), 2);
|
||||
assert_eq!(get_chat_contacts(&a2, a2_chat_id).await?.len(), 2);
|
||||
assert_eq!(get_past_chat_contacts(&a1, a1_chat_id).await?.len(), 0);
|
||||
assert_eq!(get_past_chat_contacts(&a2, a2_chat_id).await?.len(), 0);
|
||||
|
||||
// rename the group
|
||||
set_chat_name(&a1, a1_chat_id, "bar").await?;
|
||||
@@ -5195,6 +5255,8 @@ mod tests {
|
||||
);
|
||||
assert_eq!(get_chat_contacts(&a1, a1_chat_id).await?.len(), 1);
|
||||
assert_eq!(get_chat_contacts(&a2, a2_chat_id).await?.len(), 1);
|
||||
assert_eq!(get_past_chat_contacts(&a1, a1_chat_id).await?.len(), 1);
|
||||
assert_eq!(get_past_chat_contacts(&a2, a2_chat_id).await?.len(), 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -5204,7 +5266,7 @@ mod tests {
|
||||
let _n = TimeShiftFalsePositiveNote;
|
||||
|
||||
// Alice creates a group with Bob, Claire and Daisy and then removes Claire and Daisy
|
||||
// (sleep() is needed as otherwise smeared time from Alice looks to Bob like messages from the future which are all set to "now" then)
|
||||
// (time shift is needed as otherwise smeared time from Alice looks to Bob like messages from the future which are all set to "now" then)
|
||||
let alice = TestContext::new_alice().await;
|
||||
|
||||
let bob_id = Contact::create(&alice, "", "bob@example.net").await?;
|
||||
@@ -5219,17 +5281,17 @@ mod tests {
|
||||
|
||||
add_contact_to_chat(&alice, alice_chat_id, claire_id).await?;
|
||||
let add2 = alice.pop_sent_msg().await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
|
||||
SystemTime::shift(Duration::from_millis(1100));
|
||||
|
||||
add_contact_to_chat(&alice, alice_chat_id, daisy_id).await?;
|
||||
let add3 = alice.pop_sent_msg().await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
|
||||
SystemTime::shift(Duration::from_millis(1100));
|
||||
|
||||
assert_eq!(get_chat_contacts(&alice, alice_chat_id).await?.len(), 4);
|
||||
|
||||
remove_contact_from_chat(&alice, alice_chat_id, claire_id).await?;
|
||||
let remove1 = alice.pop_sent_msg().await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
|
||||
SystemTime::shift(Duration::from_millis(1100));
|
||||
|
||||
remove_contact_from_chat(&alice, alice_chat_id, daisy_id).await?;
|
||||
let remove2 = alice.pop_sent_msg().await;
|
||||
@@ -5239,8 +5301,8 @@ mod tests {
|
||||
// Bob receives the add and deletion messages out of order
|
||||
let bob = TestContext::new_bob().await;
|
||||
bob.recv_msg(&add1).await;
|
||||
bob.recv_msg(&add3).await;
|
||||
let bob_chat_id = bob.recv_msg(&add2).await.chat_id;
|
||||
let bob_chat_id = bob.recv_msg(&add3).await.chat_id;
|
||||
bob.recv_msg_trash(&add2).await; // No-op addition message is trashed.
|
||||
assert_eq!(get_chat_contacts(&bob, bob_chat_id).await?.len(), 4);
|
||||
|
||||
bob.recv_msg(&remove2).await;
|
||||
@@ -5279,7 +5341,8 @@ mod tests {
|
||||
/// Test that group updates are robust to lost messages and eventual out of order arrival.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_modify_chat_lost() -> Result<()> {
|
||||
let alice = TestContext::new_alice().await;
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = tcm.alice().await;
|
||||
|
||||
let bob_id = Contact::create(&alice, "", "bob@example.net").await?;
|
||||
let claire_id = Contact::create(&alice, "", "claire@foo.de").await?;
|
||||
@@ -5292,16 +5355,16 @@ mod tests {
|
||||
|
||||
send_text_msg(&alice, alice_chat_id, "populate".to_string()).await?;
|
||||
let add = alice.pop_sent_msg().await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
|
||||
SystemTime::shift(Duration::from_millis(1100));
|
||||
|
||||
remove_contact_from_chat(&alice, alice_chat_id, claire_id).await?;
|
||||
let remove1 = alice.pop_sent_msg().await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
|
||||
SystemTime::shift(Duration::from_millis(1100));
|
||||
|
||||
remove_contact_from_chat(&alice, alice_chat_id, daisy_id).await?;
|
||||
let remove2 = alice.pop_sent_msg().await;
|
||||
|
||||
let bob = TestContext::new_bob().await;
|
||||
let bob = tcm.bob().await;
|
||||
|
||||
bob.recv_msg(&add).await;
|
||||
let bob_chat_id = bob.get_last_msg().await.chat_id;
|
||||
@@ -5314,7 +5377,7 @@ mod tests {
|
||||
|
||||
// Eventually, first removal message arrives.
|
||||
// This has no effect.
|
||||
bob.recv_msg_trash(&remove1).await;
|
||||
bob.recv_msg(&remove1).await;
|
||||
assert_eq!(get_chat_contacts(&bob, bob_chat_id).await?.len(), 2);
|
||||
Ok(())
|
||||
}
|
||||
@@ -7716,6 +7779,121 @@ mod tests {
|
||||
self_chat.set_draft(&alice, Some(&mut msg)).await.unwrap();
|
||||
let draft2 = self_chat.get_draft(&alice).await?.unwrap();
|
||||
assert_eq!(draft1.timestamp_sort, draft2.timestamp_sort);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test group consistency.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_add_member_bug() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let alice_bob_contact_id = Contact::create(alice, "Bob", "bob@example.net").await?;
|
||||
let alice_fiona_contact_id = Contact::create(alice, "Fiona", "fiona@example.net").await?;
|
||||
|
||||
// Create a group.
|
||||
let alice_chat_id =
|
||||
create_group_chat(alice, ProtectionStatus::Unprotected, "Group chat").await?;
|
||||
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
|
||||
add_contact_to_chat(alice, alice_chat_id, alice_fiona_contact_id).await?;
|
||||
|
||||
// Promote the group.
|
||||
let alice_sent_msg = alice
|
||||
.send_text(alice_chat_id, "Hi! I created a group.")
|
||||
.await;
|
||||
let bob_received_msg = bob.recv_msg(&alice_sent_msg).await;
|
||||
|
||||
let bob_chat_id = bob_received_msg.get_chat_id();
|
||||
bob_chat_id.accept(bob).await?;
|
||||
|
||||
// Alice removes Fiona from the chat.
|
||||
remove_contact_from_chat(alice, alice_chat_id, alice_fiona_contact_id).await?;
|
||||
let _alice_sent_add_msg = alice.pop_sent_msg().await;
|
||||
|
||||
SystemTime::shift(Duration::from_secs(3600));
|
||||
|
||||
// Bob sends a message
|
||||
// to Alice and Fiona because he still has not received
|
||||
// a message about Fiona being removed.
|
||||
let bob_sent_msg = bob.send_text(bob_chat_id, "Hi Alice!").await;
|
||||
|
||||
// Alice receives a message.
|
||||
// This should not add Fiona back.
|
||||
let _alice_received_msg = alice.recv_msg(&bob_sent_msg).await;
|
||||
|
||||
assert_eq!(get_chat_contacts(alice, alice_chat_id).await?.len(), 2);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that tombstones for past members are added to chats_contacts table
|
||||
/// even if the row did not exist before.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_past_members() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
|
||||
let alice = &tcm.alice().await;
|
||||
let alice_fiona_contact_id = Contact::create(alice, "Fiona", "fiona@example.net").await?;
|
||||
|
||||
let alice_chat_id =
|
||||
create_group_chat(alice, ProtectionStatus::Unprotected, "Group chat").await?;
|
||||
add_contact_to_chat(alice, alice_chat_id, alice_fiona_contact_id).await?;
|
||||
alice
|
||||
.send_text(alice_chat_id, "Hi! I created a group.")
|
||||
.await;
|
||||
remove_contact_from_chat(alice, alice_chat_id, alice_fiona_contact_id).await?;
|
||||
assert_eq!(get_past_chat_contacts(alice, alice_chat_id).await?.len(), 1);
|
||||
|
||||
let bob = &tcm.bob().await;
|
||||
let bob_addr = bob.get_config(Config::Addr).await?.unwrap();
|
||||
let alice_bob_contact_id = Contact::create(alice, "Bob", &bob_addr).await?;
|
||||
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
|
||||
|
||||
let add_message = alice.pop_sent_msg().await;
|
||||
let bob_add_message = bob.recv_msg(&add_message).await;
|
||||
let bob_chat_id = bob_add_message.chat_id;
|
||||
assert_eq!(get_chat_contacts(bob, bob_chat_id).await?.len(), 2);
|
||||
assert_eq!(get_past_chat_contacts(bob, bob_chat_id).await?.len(), 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn non_member_cannot_modify_member_list() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let bob_addr = bob.get_config(Config::Addr).await?.unwrap();
|
||||
let alice_bob_contact_id = Contact::create(alice, "Bob", &bob_addr).await?;
|
||||
|
||||
let alice_chat_id =
|
||||
create_group_chat(alice, ProtectionStatus::Unprotected, "Group chat").await?;
|
||||
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
|
||||
let alice_sent_msg = alice
|
||||
.send_text(alice_chat_id, "Hi! I created a group.")
|
||||
.await;
|
||||
let bob_received_msg = bob.recv_msg(&alice_sent_msg).await;
|
||||
let bob_chat_id = bob_received_msg.get_chat_id();
|
||||
bob_chat_id.accept(bob).await?;
|
||||
|
||||
let bob_fiona_contact_id = Contact::create(bob, "Fiona", "fiona@example.net").await?;
|
||||
|
||||
// Alice removes Bob and Bob adds Fiona at the same time.
|
||||
remove_contact_from_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
|
||||
add_contact_to_chat(bob, bob_chat_id, bob_fiona_contact_id).await?;
|
||||
|
||||
let bob_sent_add_msg = bob.pop_sent_msg().await;
|
||||
|
||||
// Alice ignores Bob's message because Bob is not a member.
|
||||
assert_eq!(get_chat_contacts(alice, alice_chat_id).await?.len(), 1);
|
||||
alice.recv_msg(&bob_sent_add_msg).await;
|
||||
assert_eq!(get_chat_contacts(alice, alice_chat_id).await?.len(), 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ impl Chatlist {
|
||||
ORDER BY timestamp DESC, id DESC LIMIT 1)
|
||||
WHERE c.id>9
|
||||
AND c.blocked!=1
|
||||
AND c.id IN(SELECT chat_id FROM chats_contacts WHERE contact_id=?2)
|
||||
AND c.id IN(SELECT chat_id FROM chats_contacts WHERE contact_id=?2 AND add_timestamp >= remove_timestamp)
|
||||
GROUP BY c.id
|
||||
ORDER BY c.archived=?3 DESC, IFNULL(m.timestamp,c.created_timestamp) DESC, m.id DESC;",
|
||||
(MessageState::OutDraft, query_contact_id, ChatVisibility::Pinned),
|
||||
@@ -261,7 +261,7 @@ impl Chatlist {
|
||||
WHERE c.id>9 AND c.id!=?
|
||||
AND c.blocked=0
|
||||
AND NOT c.archived=?
|
||||
AND (c.type!=? OR c.id IN(SELECT chat_id FROM chats_contacts WHERE contact_id=?))
|
||||
AND (c.type!=? OR c.id IN(SELECT chat_id FROM chats_contacts WHERE contact_id=? AND add_timestamp >= remove_timestamp))
|
||||
GROUP BY c.id
|
||||
ORDER BY c.id=? DESC, c.archived=? DESC, IFNULL(m.timestamp,c.created_timestamp) DESC, m.id DESC;",
|
||||
(
|
||||
@@ -550,7 +550,7 @@ mod tests {
|
||||
let chats = Chatlist::try_load(&t, 0, Some("is:unread"), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(chats.len() == 1);
|
||||
assert_eq!(chats.len(), 1);
|
||||
|
||||
let chats = Chatlist::try_load(&t, DC_GCL_ARCHIVED_ONLY, None, None)
|
||||
.await
|
||||
@@ -576,7 +576,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap();
|
||||
assert!(chats.len() == 3);
|
||||
assert_eq!(chats.len(), 3);
|
||||
assert!(!Chat::load_from_db(&t, chats.get_chat_id(0).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -585,7 +585,7 @@ mod tests {
|
||||
let chats = Chatlist::try_load(&t, DC_GCL_FOR_FORWARDING, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(chats.len() == 2); // device chat cannot be written and is skipped on forwarding
|
||||
assert_eq!(chats.len(), 2); // device chat cannot be written and is skipped on forwarding
|
||||
assert!(Chat::load_from_db(&t, chats.get_chat_id(0).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -597,7 +597,7 @@ mod tests {
|
||||
let chats = Chatlist::try_load(&t, DC_GCL_FOR_FORWARDING, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(chats.len() == 1);
|
||||
assert_eq!(chats.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
|
||||
@@ -61,10 +61,7 @@ macro_rules! progress {
|
||||
impl Context {
|
||||
/// Checks if the context is already configured.
|
||||
pub async fn is_configured(&self) -> Result<bool> {
|
||||
self.sql
|
||||
.get_raw_config_bool("configured")
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
self.sql.get_raw_config_bool("configured").await
|
||||
}
|
||||
|
||||
/// Configures this account with the currently set parameters.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Contacts module
|
||||
|
||||
use std::cmp::{min, Reverse};
|
||||
use std::collections::BinaryHeap;
|
||||
use std::collections::{BinaryHeap, HashSet};
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
@@ -34,7 +34,6 @@ use crate::message::MessageState;
|
||||
use crate::mimeparser::AvatarAction;
|
||||
use crate::param::{Param, Params};
|
||||
use crate::peerstate::Peerstate;
|
||||
use crate::sql::{self, params_iter};
|
||||
use crate::sync::{self, Sync::*};
|
||||
use crate::tools::{duration_to_str, get_abs_path, smeared_time, time, SystemTime};
|
||||
use crate::{chat, chatlist_events, stock_str};
|
||||
@@ -114,7 +113,8 @@ impl ContactId {
|
||||
SET gossiped_timestamp=0
|
||||
WHERE EXISTS (SELECT 1 FROM chats_contacts
|
||||
WHERE chats_contacts.chat_id=chats.id
|
||||
AND chats_contacts.contact_id=?)",
|
||||
AND chats_contacts.contact_id=?
|
||||
AND chats_contacts.add_timestamp >= chats_contacts.remove_timestamp)",
|
||||
(self,),
|
||||
)
|
||||
.await?;
|
||||
@@ -1039,7 +1039,11 @@ impl Contact {
|
||||
listflags: u32,
|
||||
query: Option<&str>,
|
||||
) -> Result<Vec<ContactId>> {
|
||||
let self_addrs = context.get_all_self_addrs().await?;
|
||||
let self_addrs = context
|
||||
.get_all_self_addrs()
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect::<HashSet<_>>();
|
||||
let mut add_self = false;
|
||||
let mut ret = Vec::new();
|
||||
let flag_verified_only = (listflags & DC_GCL_VERIFIED_ONLY) != 0;
|
||||
@@ -1054,29 +1058,32 @@ impl Contact {
|
||||
context
|
||||
.sql
|
||||
.query_map(
|
||||
&format!(
|
||||
"SELECT c.id FROM contacts c \
|
||||
"SELECT c.id, c.addr FROM contacts c
|
||||
LEFT JOIN acpeerstates ps ON c.addr=ps.addr \
|
||||
WHERE c.addr NOT IN ({})
|
||||
AND c.id>? \
|
||||
WHERE c.id>?
|
||||
AND c.origin>=? \
|
||||
AND c.blocked=0 \
|
||||
AND (iif(c.name='',c.authname,c.name) LIKE ? OR c.addr LIKE ?) \
|
||||
AND (1=? OR LENGTH(ps.verified_key_fingerprint)!=0) \
|
||||
ORDER BY c.last_seen DESC, c.id DESC;",
|
||||
sql::repeat_vars(self_addrs.len())
|
||||
),
|
||||
rusqlite::params_from_iter(params_iter(&self_addrs).chain(params_slice![
|
||||
(
|
||||
ContactId::LAST_SPECIAL,
|
||||
minimal_origin,
|
||||
s3str_like_cmd,
|
||||
s3str_like_cmd,
|
||||
if flag_verified_only { 0i32 } else { 1i32 }
|
||||
])),
|
||||
|row| row.get::<_, ContactId>(0),
|
||||
|ids| {
|
||||
for id in ids {
|
||||
ret.push(id?);
|
||||
&s3str_like_cmd,
|
||||
&s3str_like_cmd,
|
||||
if flag_verified_only { 0i32 } else { 1i32 },
|
||||
),
|
||||
|row| {
|
||||
let id: ContactId = row.get(0)?;
|
||||
let addr: String = row.get(1)?;
|
||||
Ok((id, addr))
|
||||
},
|
||||
|rows| {
|
||||
for row in rows {
|
||||
let (id, addr) = row?;
|
||||
if !self_addrs.contains(&addr) {
|
||||
ret.push(id);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
@@ -1109,23 +1116,23 @@ impl Contact {
|
||||
context
|
||||
.sql
|
||||
.query_map(
|
||||
&format!(
|
||||
"SELECT id FROM contacts
|
||||
WHERE addr NOT IN ({})
|
||||
AND id>?
|
||||
"SELECT id, addr FROM contacts
|
||||
WHERE id>?
|
||||
AND origin>=?
|
||||
AND blocked=0
|
||||
ORDER BY last_seen DESC, id DESC;",
|
||||
sql::repeat_vars(self_addrs.len())
|
||||
),
|
||||
rusqlite::params_from_iter(
|
||||
params_iter(&self_addrs)
|
||||
.chain(params_slice![ContactId::LAST_SPECIAL, minimal_origin]),
|
||||
),
|
||||
|row| row.get::<_, ContactId>(0),
|
||||
|ids| {
|
||||
for id in ids {
|
||||
ret.push(id?);
|
||||
(ContactId::LAST_SPECIAL, minimal_origin),
|
||||
|row| {
|
||||
let id: ContactId = row.get(0)?;
|
||||
let addr: String = row.get(1)?;
|
||||
Ok((id, addr))
|
||||
},
|
||||
|rows| {
|
||||
for row in rows {
|
||||
let (id, addr) = row?;
|
||||
if !self_addrs.contains(&addr) {
|
||||
ret.push(id);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
|
||||
128
src/e2ee.rs
128
src/e2ee.rs
@@ -40,20 +40,17 @@ impl EncryptHelper {
|
||||
|
||||
/// Determines if we can and should encrypt.
|
||||
///
|
||||
/// For encryption to be enabled, `e2ee_guaranteed` should be true, or strictly more than a half
|
||||
/// of peerstates should prefer encryption. Own preference is counted equally to peer
|
||||
/// preferences, even if message copy is not sent to self.
|
||||
///
|
||||
/// `e2ee_guaranteed` should be set to true for replies to encrypted messages (as required by
|
||||
/// Autocrypt Level 1, version 1.1) and for messages sent in protected groups.
|
||||
///
|
||||
/// Returns an error if `e2ee_guaranteed` is true, but one or more keys are missing.
|
||||
pub fn should_encrypt(
|
||||
pub(crate) async fn should_encrypt(
|
||||
&self,
|
||||
context: &Context,
|
||||
e2ee_guaranteed: bool,
|
||||
peerstates: &[(Option<Peerstate>, String)],
|
||||
) -> Result<bool> {
|
||||
let is_chatmail = context.is_chatmail().await?;
|
||||
let mut prefer_encrypt_count = if self.prefer_encrypt == EncryptPreference::Mutual {
|
||||
1
|
||||
} else {
|
||||
@@ -64,10 +61,15 @@ impl EncryptHelper {
|
||||
Some(peerstate) => {
|
||||
let prefer_encrypt = peerstate.prefer_encrypt;
|
||||
info!(context, "Peerstate for {addr:?} is {prefer_encrypt}.");
|
||||
match peerstate.prefer_encrypt {
|
||||
EncryptPreference::NoPreference | EncryptPreference::Reset => {}
|
||||
EncryptPreference::Mutual => prefer_encrypt_count += 1,
|
||||
};
|
||||
if match peerstate.prefer_encrypt {
|
||||
EncryptPreference::NoPreference | EncryptPreference::Reset => {
|
||||
(peerstate.prefer_encrypt != EncryptPreference::Reset || is_chatmail)
|
||||
&& self.prefer_encrypt == EncryptPreference::Mutual
|
||||
}
|
||||
EncryptPreference::Mutual => true,
|
||||
} {
|
||||
prefer_encrypt_count += 1;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let msg = format!("Peerstate for {addr:?} missing, cannot encrypt");
|
||||
@@ -170,9 +172,11 @@ pub async fn ensure_secret_key_exists(context: &Context) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::chat::send_text_msg;
|
||||
use crate::key::DcKey;
|
||||
use crate::message::{Message, Viewtype};
|
||||
use crate::param::Param;
|
||||
use crate::receive_imf::receive_imf;
|
||||
use crate::test_utils::{bob_keypair, TestContext, TestContextManager};
|
||||
|
||||
mod ensure_secret_key_exists {
|
||||
@@ -320,29 +324,109 @@ Sent with my Delta Chat Messenger: https://delta.chat";
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_should_encrypt() {
|
||||
async fn test_should_encrypt() -> Result<()> {
|
||||
let t = TestContext::new_alice().await;
|
||||
assert!(t.get_config_bool(Config::E2eeEnabled).await?);
|
||||
let encrypt_helper = EncryptHelper::new(&t).await.unwrap();
|
||||
|
||||
// test with EncryptPreference::NoPreference:
|
||||
// if e2ee_eguaranteed is unset, there is no encryption as not more than half of peers want encryption
|
||||
let ps = new_peerstates(EncryptPreference::NoPreference);
|
||||
assert!(encrypt_helper.should_encrypt(&t, true, &ps).unwrap());
|
||||
assert!(!encrypt_helper.should_encrypt(&t, false, &ps).unwrap());
|
||||
assert!(encrypt_helper.should_encrypt(&t, true, &ps).await?);
|
||||
// Own preference is `Mutual` and we have the peer's key.
|
||||
assert!(encrypt_helper.should_encrypt(&t, false, &ps).await?);
|
||||
|
||||
// test with EncryptPreference::Reset
|
||||
let ps = new_peerstates(EncryptPreference::Reset);
|
||||
assert!(encrypt_helper.should_encrypt(&t, true, &ps).unwrap());
|
||||
assert!(!encrypt_helper.should_encrypt(&t, false, &ps).unwrap());
|
||||
assert!(encrypt_helper.should_encrypt(&t, true, &ps).await?);
|
||||
assert!(!encrypt_helper.should_encrypt(&t, false, &ps).await?);
|
||||
|
||||
// test with EncryptPreference::Mutual (self is also Mutual)
|
||||
let ps = new_peerstates(EncryptPreference::Mutual);
|
||||
assert!(encrypt_helper.should_encrypt(&t, true, &ps).unwrap());
|
||||
assert!(encrypt_helper.should_encrypt(&t, false, &ps).unwrap());
|
||||
assert!(encrypt_helper.should_encrypt(&t, true, &ps).await?);
|
||||
assert!(encrypt_helper.should_encrypt(&t, false, &ps).await?);
|
||||
|
||||
// test with missing peerstate
|
||||
let ps = vec![(None, "bob@foo.bar".to_string())];
|
||||
assert!(encrypt_helper.should_encrypt(&t, true, &ps).is_err());
|
||||
assert!(!encrypt_helper.should_encrypt(&t, false, &ps).unwrap());
|
||||
assert!(encrypt_helper.should_encrypt(&t, true, &ps).await.is_err());
|
||||
assert!(!encrypt_helper.should_encrypt(&t, false, &ps).await?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_should_encrypt_e2ee_disabled() -> Result<()> {
|
||||
let t = &TestContext::new_alice().await;
|
||||
t.set_config_bool(Config::E2eeEnabled, false).await?;
|
||||
let encrypt_helper = EncryptHelper::new(t).await.unwrap();
|
||||
|
||||
let ps = new_peerstates(EncryptPreference::NoPreference);
|
||||
assert!(!encrypt_helper.should_encrypt(t, false, &ps).await?);
|
||||
|
||||
let ps = new_peerstates(EncryptPreference::Reset);
|
||||
assert!(encrypt_helper.should_encrypt(t, true, &ps).await?);
|
||||
|
||||
let mut ps = new_peerstates(EncryptPreference::Mutual);
|
||||
// Own preference is `NoPreference` and there's no majority with `Mutual`.
|
||||
assert!(!encrypt_helper.should_encrypt(t, false, &ps).await?);
|
||||
// Now the majority wants to encrypt. Let's encrypt, anyway there are other cases when we
|
||||
// can't send unencrypted, e.g. protected groups.
|
||||
ps.push(ps[0].clone());
|
||||
assert!(encrypt_helper.should_encrypt(t, false, &ps).await?);
|
||||
|
||||
// Test with missing peerstate.
|
||||
let ps = vec![(None, "bob@foo.bar".to_string())];
|
||||
assert!(encrypt_helper.should_encrypt(t, true, &ps).await.is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_chatmail_prefers_to_encrypt() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
bob.set_config_bool(Config::IsChatmail, true).await?;
|
||||
|
||||
let bob_chat_id = tcm
|
||||
.send_recv_accept(alice, bob, "Hello from DC")
|
||||
.await
|
||||
.chat_id;
|
||||
receive_imf(
|
||||
bob,
|
||||
b"From: alice@example.org\n\
|
||||
To: bob@example.net\n\
|
||||
Message-ID: <2222@example.org>\n\
|
||||
Date: Sun, 22 Mar 3000 22:37:58 +0000\n\
|
||||
\n\
|
||||
Hello from another MUA\n",
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
send_text_msg(bob, bob_chat_id, "hi".to_string()).await?;
|
||||
let sent_msg = bob.pop_sent_msg().await;
|
||||
let msg = Message::load_from_db(bob, sent_msg.sender_msg_id).await?;
|
||||
assert!(msg.get_showpadlock());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_chatmail_can_send_unencrypted() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let bob = &tcm.bob().await;
|
||||
bob.set_config_bool(Config::IsChatmail, true).await?;
|
||||
let bob_chat_id = receive_imf(
|
||||
bob,
|
||||
b"From: alice@example.org\n\
|
||||
To: bob@example.net\n\
|
||||
Message-ID: <2222@example.org>\n\
|
||||
Date: Sun, 22 Mar 3000 22:37:58 +0000\n\
|
||||
\n\
|
||||
Hello\n",
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
.unwrap()
|
||||
.chat_id;
|
||||
bob_chat_id.accept(bob).await?;
|
||||
send_text_msg(bob, bob_chat_id, "hi".to_string()).await?;
|
||||
let sent_msg = bob.pop_sent_msg().await;
|
||||
let msg = Message::load_from_db(bob, sent_msg.sender_msg_id).await?;
|
||||
assert!(!msg.get_showpadlock());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,15 @@ pub enum HeaderDef {
|
||||
ChatGroupMemberAdded,
|
||||
ChatContent,
|
||||
|
||||
/// Past members of the group.
|
||||
ChatGroupPastMembers,
|
||||
|
||||
/// Space-separated timestamps of member addition
|
||||
/// for members listed in the `To` field
|
||||
/// followed by timestamps of member removal
|
||||
/// for members listed in the `Chat-Group-Past-Members` field.
|
||||
ChatGroupMemberTimestamps,
|
||||
|
||||
/// Duration of the attached media file.
|
||||
ChatDuration,
|
||||
|
||||
|
||||
10
src/imap.rs
10
src/imap.rs
@@ -1452,9 +1452,7 @@ impl Session {
|
||||
|
||||
let is_seen = fetch_response.flags().any(|flag| flag == Flag::Seen);
|
||||
|
||||
let rfc724_mid = if let Some(rfc724_mid) = uid_message_ids.get(&request_uid) {
|
||||
rfc724_mid
|
||||
} else {
|
||||
let Some(rfc724_mid) = uid_message_ids.get(&request_uid) else {
|
||||
error!(
|
||||
context,
|
||||
"No Message-ID corresponding to UID {} passed in uid_messsage_ids.",
|
||||
@@ -1591,10 +1589,8 @@ impl Session {
|
||||
};
|
||||
|
||||
if self.can_metadata() && self.can_push() {
|
||||
let device_token_changed = context
|
||||
.get_config(Config::DeviceToken)
|
||||
.await?
|
||||
.map_or(true, |config_token| device_token != config_token);
|
||||
let device_token_changed =
|
||||
context.get_config(Config::DeviceToken).await?.as_ref() != Some(&device_token);
|
||||
|
||||
if device_token_changed {
|
||||
let folder = context
|
||||
|
||||
@@ -1111,7 +1111,9 @@ impl Message {
|
||||
|
||||
/// Updates message state from the vCard attachment.
|
||||
pub(crate) async fn try_set_vcard(&mut self, context: &Context, path: &Path) -> Result<()> {
|
||||
let vcard = fs::read(path).await.context("Could not read {path}")?;
|
||||
let vcard = fs::read(path)
|
||||
.await
|
||||
.with_context(|| format!("Could not read {path:?}"))?;
|
||||
if let Some(summary) = get_vcard_summary(&vcard) {
|
||||
self.param.set(Param::Summary1, summary);
|
||||
} else {
|
||||
@@ -1667,12 +1669,12 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
|
||||
.set_config_internal(Config::LastMsgId, Some(&last_msg_id.to_u32().to_string()))
|
||||
.await?;
|
||||
|
||||
let msgs = context
|
||||
.sql
|
||||
.query_map(
|
||||
&format!(
|
||||
let mut msgs = Vec::with_capacity(msg_ids.len());
|
||||
for &id in &msg_ids {
|
||||
if let Some(msg) = context
|
||||
.sql
|
||||
.query_row_optional(
|
||||
"SELECT
|
||||
m.id AS id,
|
||||
m.chat_id AS chat_id,
|
||||
m.state AS state,
|
||||
m.download_state as download_state,
|
||||
@@ -1683,39 +1685,39 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
|
||||
c.archived AS archived,
|
||||
c.blocked AS blocked
|
||||
FROM msgs m LEFT JOIN chats c ON c.id=m.chat_id
|
||||
WHERE m.id IN ({}) AND m.chat_id>9",
|
||||
sql::repeat_vars(msg_ids.len())
|
||||
),
|
||||
rusqlite::params_from_iter(&msg_ids),
|
||||
|row| {
|
||||
let id: MsgId = row.get("id")?;
|
||||
let chat_id: ChatId = row.get("chat_id")?;
|
||||
let state: MessageState = row.get("state")?;
|
||||
let download_state: DownloadState = row.get("download_state")?;
|
||||
let param: Params = row.get::<_, String>("param")?.parse().unwrap_or_default();
|
||||
let from_id: ContactId = row.get("from_id")?;
|
||||
let rfc724_mid: String = row.get("rfc724_mid")?;
|
||||
let visibility: ChatVisibility = row.get("archived")?;
|
||||
let blocked: Option<Blocked> = row.get("blocked")?;
|
||||
let ephemeral_timer: EphemeralTimer = row.get("ephemeral_timer")?;
|
||||
Ok((
|
||||
(
|
||||
id,
|
||||
chat_id,
|
||||
state,
|
||||
download_state,
|
||||
param,
|
||||
from_id,
|
||||
rfc724_mid,
|
||||
visibility,
|
||||
blocked.unwrap_or_default(),
|
||||
),
|
||||
ephemeral_timer,
|
||||
))
|
||||
},
|
||||
|rows| rows.collect::<Result<Vec<_>, _>>().map_err(Into::into),
|
||||
)
|
||||
.await?;
|
||||
WHERE m.id=? AND m.chat_id>9",
|
||||
(id,),
|
||||
|row| {
|
||||
let chat_id: ChatId = row.get("chat_id")?;
|
||||
let state: MessageState = row.get("state")?;
|
||||
let download_state: DownloadState = row.get("download_state")?;
|
||||
let param: Params = row.get::<_, String>("param")?.parse().unwrap_or_default();
|
||||
let from_id: ContactId = row.get("from_id")?;
|
||||
let rfc724_mid: String = row.get("rfc724_mid")?;
|
||||
let visibility: ChatVisibility = row.get("archived")?;
|
||||
let blocked: Option<Blocked> = row.get("blocked")?;
|
||||
let ephemeral_timer: EphemeralTimer = row.get("ephemeral_timer")?;
|
||||
Ok((
|
||||
(
|
||||
id,
|
||||
chat_id,
|
||||
state,
|
||||
download_state,
|
||||
param,
|
||||
from_id,
|
||||
rfc724_mid,
|
||||
visibility,
|
||||
blocked.unwrap_or_default(),
|
||||
),
|
||||
ephemeral_timer,
|
||||
))
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
msgs.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
if msgs
|
||||
.iter()
|
||||
|
||||
@@ -66,8 +66,36 @@ pub struct MimeFactory {
|
||||
|
||||
selfstatus: String,
|
||||
|
||||
/// Vector of pairs of recipient name and address
|
||||
recipients: Vec<(String, String)>,
|
||||
/// Vector of actual recipient addresses.
|
||||
///
|
||||
/// This is the list of addresses the message should be sent to.
|
||||
/// It is not the same as the `To` header,
|
||||
/// because in case of "member removed" message
|
||||
/// removed member is in the recipient list,
|
||||
/// but not in the `To` header.
|
||||
/// In case of broadcast lists there are multiple recipients,
|
||||
/// but the `To` header has no members.
|
||||
///
|
||||
/// If `bcc_self` configuration is enabled,
|
||||
/// this list will be extended with own address later,
|
||||
/// but `MimeFactory` is not responsible for this.
|
||||
recipients: Vec<String>,
|
||||
|
||||
/// Vector of pairs of recipient name and address that goes into the `To` field.
|
||||
///
|
||||
/// The list of actual message recipient addresses may be different,
|
||||
/// e.g. if members are hidden for broadcast lists.
|
||||
to: Vec<(String, String)>,
|
||||
|
||||
/// Vector of pairs of past group member names and addresses.
|
||||
past_members: Vec<(String, String)>,
|
||||
|
||||
/// Timestamps of the members in the same order as in the `recipients`
|
||||
/// followed by `past_members`.
|
||||
///
|
||||
/// If this is not empty, its length
|
||||
/// should be the sum of `recipients` and `past_members` length.
|
||||
member_timestamps: Vec<i64>,
|
||||
|
||||
timestamp: i64,
|
||||
loaded: Loaded,
|
||||
@@ -128,6 +156,7 @@ impl MimeFactory {
|
||||
pub async fn from_msg(context: &Context, msg: Message) -> Result<MimeFactory> {
|
||||
let chat = Chat::load_from_db(context, msg.chat_id).await?;
|
||||
let attach_profile_data = Self::should_attach_profile_data(&msg);
|
||||
let undisclosed_recipients = chat.typ == Chattype::Broadcast;
|
||||
|
||||
let from_addr = context.get_primary_self_addr().await?;
|
||||
let config_displayname = context
|
||||
@@ -145,47 +174,101 @@ impl MimeFactory {
|
||||
(name, None)
|
||||
};
|
||||
|
||||
let mut recipients = Vec::with_capacity(5);
|
||||
let mut recipients = Vec::new();
|
||||
let mut to = Vec::new();
|
||||
let mut past_members = Vec::new();
|
||||
let mut member_timestamps = Vec::new();
|
||||
let mut recipient_ids = HashSet::new();
|
||||
let mut req_mdn = false;
|
||||
|
||||
if chat.is_self_talk() {
|
||||
if msg.param.get_cmd() == SystemMessage::AutocryptSetupMessage {
|
||||
recipients.push((from_displayname.to_string(), from_addr.to_string()));
|
||||
recipients.push(from_addr.to_string());
|
||||
to.push((from_displayname.to_string(), from_addr.to_string()));
|
||||
}
|
||||
} else if chat.is_mailing_list() {
|
||||
let list_post = chat
|
||||
.param
|
||||
.get(Param::ListPost)
|
||||
.context("Can't write to mailinglist without ListPost param")?;
|
||||
recipients.push(("".to_string(), list_post.to_string()));
|
||||
to.push(("".to_string(), list_post.to_string()));
|
||||
recipients.push(list_post.to_string());
|
||||
} else {
|
||||
let email_to_remove = if msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup {
|
||||
msg.param.get(Param::Arg)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
context
|
||||
.sql
|
||||
.query_map(
|
||||
"SELECT c.authname, c.addr, c.id \
|
||||
FROM chats_contacts cc \
|
||||
LEFT JOIN contacts c ON cc.contact_id=c.id \
|
||||
WHERE cc.chat_id=? AND cc.contact_id>9;",
|
||||
(msg.chat_id,),
|
||||
"SELECT c.authname, c.addr, c.id, cc.add_timestamp, cc.remove_timestamp
|
||||
FROM chats_contacts cc
|
||||
LEFT JOIN contacts c ON cc.contact_id=c.id
|
||||
WHERE cc.chat_id=? AND cc.contact_id>9 OR (cc.contact_id=1 AND ?)",
|
||||
(msg.chat_id, chat.typ == Chattype::Group),
|
||||
|row| {
|
||||
let authname: String = row.get(0)?;
|
||||
let addr: String = row.get(1)?;
|
||||
let id: ContactId = row.get(2)?;
|
||||
Ok((authname, addr, id))
|
||||
let add_timestamp: i64 = row.get(3)?;
|
||||
let remove_timestamp: i64 = row.get(4)?;
|
||||
Ok((authname, addr, id, add_timestamp, remove_timestamp))
|
||||
},
|
||||
|rows| {
|
||||
let mut past_member_timestamps = Vec::new();
|
||||
|
||||
for row in rows {
|
||||
let (authname, addr, id) = row?;
|
||||
if !recipients_contain_addr(&recipients, &addr) {
|
||||
let name = match attach_profile_data {
|
||||
true => authname,
|
||||
false => "".to_string(),
|
||||
};
|
||||
recipients.push((name, addr));
|
||||
let (authname, addr, id, add_timestamp, remove_timestamp) = row?;
|
||||
let addr = if id == ContactId::SELF {
|
||||
from_addr.to_string()
|
||||
} else {
|
||||
addr
|
||||
};
|
||||
let name = match attach_profile_data {
|
||||
true => authname,
|
||||
false => "".to_string(),
|
||||
};
|
||||
if add_timestamp >= remove_timestamp {
|
||||
if !recipients_contain_addr(&to, &addr) {
|
||||
recipients.push(addr.clone());
|
||||
if !undisclosed_recipients {
|
||||
to.push((name, addr));
|
||||
member_timestamps.push(add_timestamp);
|
||||
}
|
||||
}
|
||||
recipient_ids.insert(id);
|
||||
} else {
|
||||
// Row is a tombstone,
|
||||
// member is not actually part of the group.
|
||||
if !recipients_contain_addr(&past_members, &addr) {
|
||||
if let Some(email_to_remove) = email_to_remove {
|
||||
if email_to_remove == addr {
|
||||
// This is a "member removed" message,
|
||||
// we need to notify removed member
|
||||
// that it was removed.
|
||||
recipients.push(addr.clone());
|
||||
}
|
||||
}
|
||||
if !undisclosed_recipients {
|
||||
past_members.push((name, addr));
|
||||
past_member_timestamps.push(remove_timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
recipient_ids.insert(id);
|
||||
}
|
||||
|
||||
debug_assert!(member_timestamps.len() >= to.len());
|
||||
|
||||
if to.len() > 1 {
|
||||
if let Some(position) = to.iter().position(|(_, x)| x == &from_addr) {
|
||||
to.remove(position);
|
||||
member_timestamps.remove(position);
|
||||
}
|
||||
}
|
||||
|
||||
member_timestamps.extend(past_member_timestamps);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
@@ -226,12 +309,19 @@ impl MimeFactory {
|
||||
};
|
||||
let attach_selfavatar = Self::should_attach_selfavatar(context, &msg).await;
|
||||
|
||||
debug_assert!(
|
||||
member_timestamps.is_empty()
|
||||
|| to.len() + past_members.len() == member_timestamps.len()
|
||||
);
|
||||
let factory = MimeFactory {
|
||||
from_addr,
|
||||
from_displayname,
|
||||
sender_displayname,
|
||||
selfstatus,
|
||||
recipients,
|
||||
to,
|
||||
past_members,
|
||||
member_timestamps,
|
||||
timestamp: msg.timestamp_sort,
|
||||
loaded: Loaded::Message { msg, chat },
|
||||
in_reply_to,
|
||||
@@ -259,7 +349,10 @@ impl MimeFactory {
|
||||
from_displayname: "".to_string(),
|
||||
sender_displayname: None,
|
||||
selfstatus: "".to_string(),
|
||||
recipients: vec![("".to_string(), contact.get_addr().to_string())],
|
||||
recipients: vec![contact.get_addr().to_string()],
|
||||
to: vec![("".to_string(), contact.get_addr().to_string())],
|
||||
past_members: vec![],
|
||||
member_timestamps: vec![],
|
||||
timestamp,
|
||||
loaded: Loaded::Mdn {
|
||||
rfc724_mid,
|
||||
@@ -283,11 +376,7 @@ impl MimeFactory {
|
||||
let self_addr = context.get_primary_self_addr().await?;
|
||||
|
||||
let mut res = Vec::new();
|
||||
for (_, addr) in self
|
||||
.recipients
|
||||
.iter()
|
||||
.filter(|(_, addr)| addr != &self_addr)
|
||||
{
|
||||
for addr in self.recipients.iter().filter(|&addr| *addr != self_addr) {
|
||||
res.push((Peerstate::from_addr(context, addr).await?, addr.clone()));
|
||||
}
|
||||
|
||||
@@ -475,10 +564,7 @@ impl MimeFactory {
|
||||
}
|
||||
|
||||
pub fn recipients(&self) -> Vec<String> {
|
||||
self.recipients
|
||||
.iter()
|
||||
.map(|(_, addr)| addr.clone())
|
||||
.collect()
|
||||
self.recipients.clone()
|
||||
}
|
||||
|
||||
/// Consumes a `MimeFactory` and renders it into a message which is then stored in
|
||||
@@ -488,46 +574,33 @@ impl MimeFactory {
|
||||
|
||||
let from = new_address_with_name(&self.from_displayname, self.from_addr.clone());
|
||||
|
||||
let undisclosed_recipients = match &self.loaded {
|
||||
Loaded::Message { chat, .. } => chat.typ == Chattype::Broadcast,
|
||||
Loaded::Mdn { .. } => false,
|
||||
};
|
||||
|
||||
let mut to = Vec::new();
|
||||
if undisclosed_recipients {
|
||||
for (name, addr) in &self.to {
|
||||
if name.is_empty() {
|
||||
to.push(Address::new_mailbox(addr.clone()));
|
||||
} else {
|
||||
to.push(new_address_with_name(name, addr.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut past_members = Vec::new(); // Contents of `Chat-Group-Past-Members` header.
|
||||
for (name, addr) in &self.past_members {
|
||||
if name.is_empty() {
|
||||
past_members.push(Address::new_mailbox(addr.clone()));
|
||||
} else {
|
||||
past_members.push(new_address_with_name(name, addr.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
debug_assert!(
|
||||
self.member_timestamps.is_empty()
|
||||
|| to.len() + past_members.len() == self.member_timestamps.len()
|
||||
);
|
||||
if to.is_empty() {
|
||||
to.push(Address::new_group(
|
||||
"hidden-recipients".to_string(),
|
||||
Vec::new(),
|
||||
));
|
||||
} else {
|
||||
let email_to_remove = match &self.loaded {
|
||||
Loaded::Message { msg, .. } => {
|
||||
if msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup {
|
||||
msg.param.get(Param::Arg)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Loaded::Mdn { .. } => None,
|
||||
};
|
||||
|
||||
for (name, addr) in &self.recipients {
|
||||
if let Some(email_to_remove) = email_to_remove {
|
||||
if email_to_remove == addr {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if name.is_empty() {
|
||||
to.push(Address::new_mailbox(addr.clone()));
|
||||
} else {
|
||||
to.push(new_address_with_name(name, addr.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if to.is_empty() {
|
||||
to.push(from.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Start with Internet Message Format headers in the order of the standard example
|
||||
@@ -540,6 +613,26 @@ impl MimeFactory {
|
||||
headers.push(Header::new_with_value("Sender".into(), vec![sender]).unwrap());
|
||||
}
|
||||
headers.push(Header::new_with_value("To".into(), to.clone()).unwrap());
|
||||
if !past_members.is_empty() {
|
||||
headers.push(
|
||||
Header::new_with_value("Chat-Group-Past-Members".into(), past_members.clone())
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
if !self.member_timestamps.is_empty() {
|
||||
headers.push(
|
||||
Header::new_with_value(
|
||||
"Chat-Group-Member-Timestamps".into(),
|
||||
self.member_timestamps
|
||||
.iter()
|
||||
.map(|ts| ts.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join(" "),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
let subject_str = self.subject_str(context).await?;
|
||||
let encoded_subject = if subject_str
|
||||
@@ -652,7 +745,9 @@ impl MimeFactory {
|
||||
|
||||
let peerstates = self.peerstates_for_recipients(context).await?;
|
||||
let is_encrypted = !self.should_force_plaintext()
|
||||
&& encrypt_helper.should_encrypt(context, e2ee_guaranteed, &peerstates)?;
|
||||
&& encrypt_helper
|
||||
.should_encrypt(context, e2ee_guaranteed, &peerstates)
|
||||
.await?;
|
||||
let is_securejoin_message = if let Loaded::Message { msg, .. } = &self.loaded {
|
||||
msg.param.get_cmd() == SystemMessage::SecurejoinMessage
|
||||
} else {
|
||||
@@ -2459,8 +2554,9 @@ mod tests {
|
||||
// Alice creates a group with Bob and Claire and then removes Bob.
|
||||
let alice = TestContext::new_alice().await;
|
||||
|
||||
let claire_addr = "claire@foo.de";
|
||||
let bob_id = Contact::create(&alice, "Bob", "bob@example.net").await?;
|
||||
let claire_id = Contact::create(&alice, "Claire", "claire@foo.de").await?;
|
||||
let claire_id = Contact::create(&alice, "Claire", claire_addr).await?;
|
||||
|
||||
let alice_chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "foo").await?;
|
||||
add_contact_to_chat(&alice, alice_chat_id, bob_id).await?;
|
||||
@@ -2476,10 +2572,17 @@ mod tests {
|
||||
.get_first_header("To")
|
||||
.context("no To: header parsed")?;
|
||||
let to = addrparse_header(to)?;
|
||||
let mailbox = to
|
||||
.extract_single_info()
|
||||
.context("to: field does not contain exactly one address")?;
|
||||
assert_eq!(mailbox.addr, "bob@example.net");
|
||||
for to_addr in to.iter() {
|
||||
match to_addr {
|
||||
mailparse::MailAddr::Single(ref info) => {
|
||||
// Addresses should be of existing members (Alice and Bob) and not Claire.
|
||||
assert_ne!(info.addr, claire_addr);
|
||||
}
|
||||
mailparse::MailAddr::Group(_) => {
|
||||
panic!("Group addresses are not expected here");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ use crate::param::{Param, Params};
|
||||
use crate::peerstate::Peerstate;
|
||||
use crate::simplify::{simplify, SimplifiedText};
|
||||
use crate::sync::SyncItems;
|
||||
use crate::tools::time;
|
||||
use crate::tools::{
|
||||
get_filemeta, parse_receive_headers, smeared_time, truncate_msg_text, validate_id,
|
||||
};
|
||||
@@ -57,9 +58,14 @@ pub(crate) struct MimeMessage {
|
||||
/// Message headers.
|
||||
headers: HashMap<String, String>,
|
||||
|
||||
/// Addresses are normalized and lowercase
|
||||
/// List of addresses from the `To` and `Cc` headers.
|
||||
///
|
||||
/// Addresses are normalized and lowercase.
|
||||
pub recipients: Vec<SingleInfo>,
|
||||
|
||||
/// List of addresses from the `Chat-Group-Past-Members` header.
|
||||
pub past_members: Vec<SingleInfo>,
|
||||
|
||||
/// `From:` address.
|
||||
pub from: SingleInfo,
|
||||
|
||||
@@ -232,6 +238,7 @@ impl MimeMessage {
|
||||
|
||||
let mut headers = Default::default();
|
||||
let mut recipients = Default::default();
|
||||
let mut past_members = Default::default();
|
||||
let mut from = Default::default();
|
||||
let mut list_post = Default::default();
|
||||
let mut chat_disposition_notification_to = None;
|
||||
@@ -241,6 +248,7 @@ impl MimeMessage {
|
||||
context,
|
||||
&mut headers,
|
||||
&mut recipients,
|
||||
&mut past_members,
|
||||
&mut from,
|
||||
&mut list_post,
|
||||
&mut chat_disposition_notification_to,
|
||||
@@ -261,6 +269,7 @@ impl MimeMessage {
|
||||
context,
|
||||
&mut headers,
|
||||
&mut recipients,
|
||||
&mut past_members,
|
||||
&mut from,
|
||||
&mut list_post,
|
||||
&mut chat_disposition_notification_to,
|
||||
@@ -438,6 +447,8 @@ impl MimeMessage {
|
||||
HeaderDef::ChatGroupAvatar,
|
||||
HeaderDef::ChatGroupMemberRemoved,
|
||||
HeaderDef::ChatGroupMemberAdded,
|
||||
HeaderDef::ChatGroupMemberTimestamps,
|
||||
HeaderDef::ChatGroupPastMembers,
|
||||
] {
|
||||
headers.remove(h.get_headername());
|
||||
}
|
||||
@@ -454,6 +465,7 @@ impl MimeMessage {
|
||||
context,
|
||||
&mut headers,
|
||||
&mut recipients,
|
||||
&mut past_members,
|
||||
&mut inner_from,
|
||||
&mut list_post,
|
||||
&mut chat_disposition_notification_to,
|
||||
@@ -511,6 +523,7 @@ impl MimeMessage {
|
||||
parts: Vec::new(),
|
||||
headers,
|
||||
recipients,
|
||||
past_members,
|
||||
list_post,
|
||||
from,
|
||||
from_is_signed,
|
||||
@@ -1530,10 +1543,12 @@ impl MimeMessage {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn merge_headers(
|
||||
context: &Context,
|
||||
headers: &mut HashMap<String, String>,
|
||||
recipients: &mut Vec<SingleInfo>,
|
||||
past_members: &mut Vec<SingleInfo>,
|
||||
from: &mut Option<SingleInfo>,
|
||||
list_post: &mut Option<String>,
|
||||
chat_disposition_notification_to: &mut Option<SingleInfo>,
|
||||
@@ -1562,6 +1577,11 @@ impl MimeMessage {
|
||||
if !recipients_new.is_empty() {
|
||||
*recipients = recipients_new;
|
||||
}
|
||||
let past_members_addresses =
|
||||
get_all_addresses_from_header(fields, "chat-group-past-members");
|
||||
if !past_members_addresses.is_empty() {
|
||||
*past_members = past_members_addresses;
|
||||
}
|
||||
let from_new = get_from(fields);
|
||||
if from_new.is_some() {
|
||||
*from = from_new;
|
||||
@@ -1828,6 +1848,20 @@ impl MimeMessage {
|
||||
};
|
||||
Ok(parent_timestamp)
|
||||
}
|
||||
|
||||
/// Returns parsed `Chat-Group-Member-Timestamps` header contents.
|
||||
///
|
||||
/// Returns `None` if there is no such header.
|
||||
pub fn chat_group_member_timestamps(&self) -> Option<Vec<i64>> {
|
||||
let now = time() + constants::TIMESTAMP_SENT_TOLERANCE;
|
||||
self.get_header(HeaderDef::ChatGroupMemberTimestamps)
|
||||
.map(|h| {
|
||||
h.split_ascii_whitespace()
|
||||
.filter_map(|ts| ts.parse::<i64>().ok())
|
||||
.map(|ts| std::cmp::min(now, ts))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `Autocrypt-Gossip` headers from the email and applies them to peerstates.
|
||||
|
||||
@@ -640,6 +640,9 @@ mod tests {
|
||||
fn test_invalid_proxy_url() {
|
||||
assert!(ProxyConfig::from_url("foobar://127.0.0.1:9050").is_err());
|
||||
assert!(ProxyConfig::from_url("abc").is_err());
|
||||
|
||||
// This caused panic before shadowsocks 1.22.0.
|
||||
assert!(ProxyConfig::from_url("ss://foo:bar@127.0.0.1:9999").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
|
||||
@@ -183,6 +183,8 @@ pub enum Param {
|
||||
GroupNameTimestamp = b'g',
|
||||
|
||||
/// For Chats: timestamp of member list update.
|
||||
///
|
||||
/// Deprecated 2025-01-07.
|
||||
MemberListTimestamp = b'k',
|
||||
|
||||
/// For Webxdc Message Instances: Current document name
|
||||
|
||||
@@ -417,7 +417,6 @@ async fn get_iroh_gossip_peers(ctx: &Context, msg_id: MsgId) -> Result<Vec<NodeA
|
||||
))
|
||||
})
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(Into::into)
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -711,9 +711,25 @@ impl Peerstate {
|
||||
Origin::IncomingUnknownFrom,
|
||||
)
|
||||
.await?;
|
||||
chat::remove_from_chat_contacts_table(context, *chat_id, contact_id)
|
||||
.await?;
|
||||
chat::add_to_chat_contacts_table(context, *chat_id, &[new_contact_id])
|
||||
context
|
||||
.sql
|
||||
.transaction(|transaction| {
|
||||
transaction.execute(
|
||||
"UPDATE chats_contacts
|
||||
SET remove_timestamp=MAX(add_timestamp+1, ?)
|
||||
WHERE chat_id=? AND contact_id=?",
|
||||
(timestamp, chat_id, contact_id),
|
||||
)?;
|
||||
transaction.execute(
|
||||
"INSERT INTO chats_contacts
|
||||
(chat_id, contact_id, add_timestamp)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT (chat_id, contact_id)
|
||||
DO UPDATE SET add_timestamp=MAX(remove_timestamp, ?3)",
|
||||
(chat_id, new_contact_id, timestamp),
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
context.emit_event(EventType::ChatModified(*chat_id));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Internet Message Format reception pipeline.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::iter;
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
@@ -14,7 +15,7 @@ use regex::Regex;
|
||||
use crate::aheader::EncryptPreference;
|
||||
use crate::chat::{self, Chat, ChatId, ChatIdBlocked, ProtectionStatus};
|
||||
use crate::config::Config;
|
||||
use crate::constants::{self, Blocked, Chattype, ShowEmails, DC_CHAT_ID_TRASH};
|
||||
use crate::constants::{Blocked, Chattype, ShowEmails, DC_CHAT_ID_TRASH};
|
||||
use crate::contact::{Contact, ContactId, Origin};
|
||||
use crate::context::Context;
|
||||
use crate::debug_logging::maybe_set_logging_xdc_inner;
|
||||
@@ -25,17 +26,16 @@ use crate::headerdef::{HeaderDef, HeaderDefMap};
|
||||
use crate::imap::{markseen_on_imap_table, GENERATED_PREFIX};
|
||||
use crate::log::LogExt;
|
||||
use crate::message::{
|
||||
self, rfc724_mid_exists, rfc724_mid_exists_ex, Message, MessageState, MessengerMessage, MsgId,
|
||||
Viewtype,
|
||||
self, rfc724_mid_exists, Message, MessageState, MessengerMessage, MsgId, Viewtype,
|
||||
};
|
||||
use crate::mimeparser::{parse_message_ids, AvatarAction, MimeMessage, SystemMessage};
|
||||
use crate::param::{Param, Params};
|
||||
use crate::peer_channels::{add_gossip_peer_from_header, insert_topic_stub};
|
||||
use crate::peerstate::Peerstate;
|
||||
use crate::reaction::{set_msg_reaction, Reaction};
|
||||
use crate::rusqlite::OptionalExtension;
|
||||
use crate::securejoin::{self, handle_securejoin_handshake, observe_securejoin_on_other_device};
|
||||
use crate::simplify;
|
||||
use crate::sql::{self, params_iter};
|
||||
use crate::stock_str;
|
||||
use crate::sync::Sync::*;
|
||||
use crate::tools::{self, buf_compress, remove_subject_prefix};
|
||||
@@ -345,6 +345,18 @@ pub(crate) async fn receive_imf_inner(
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let past_ids = add_or_lookup_contacts_by_address_list(
|
||||
context,
|
||||
&mime_parser.past_members,
|
||||
if !mime_parser.incoming {
|
||||
Origin::OutgoingTo
|
||||
} else if incoming_origin.is_known() {
|
||||
Origin::IncomingTo
|
||||
} else {
|
||||
Origin::IncomingUnknownTo
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
update_verified_keys(context, &mut mime_parser, from_id).await?;
|
||||
|
||||
@@ -360,7 +372,7 @@ pub(crate) async fn receive_imf_inner(
|
||||
let contact = Contact::get_by_id(context, from_id).await?;
|
||||
mime_parser.peerstate = Peerstate::from_addr(context, contact.get_addr()).await?;
|
||||
} else {
|
||||
let to_id = to_ids.first().copied().unwrap_or_default();
|
||||
let to_id = to_ids.first().copied().unwrap_or(ContactId::SELF);
|
||||
// handshake may mark contacts as verified and must be processed before chats are created
|
||||
res = observe_securejoin_on_other_device(context, &mime_parser, to_id)
|
||||
.await
|
||||
@@ -418,6 +430,7 @@ pub(crate) async fn receive_imf_inner(
|
||||
&mut mime_parser,
|
||||
imf_raw,
|
||||
&to_ids,
|
||||
&past_ids,
|
||||
rfc724_mid_orig,
|
||||
from_id,
|
||||
seen,
|
||||
@@ -440,10 +453,10 @@ pub(crate) async fn receive_imf_inner(
|
||||
// and waste traffic.
|
||||
let chat_id = received_msg.chat_id;
|
||||
if !chat_id.is_special()
|
||||
&& mime_parser
|
||||
.recipients
|
||||
.iter()
|
||||
.all(|recipient| mime_parser.gossiped_keys.contains_key(&recipient.addr))
|
||||
&& mime_parser.recipients.iter().all(|recipient| {
|
||||
recipient.addr == mime_parser.from.addr
|
||||
|| mime_parser.gossiped_keys.contains_key(&recipient.addr)
|
||||
})
|
||||
{
|
||||
info!(
|
||||
context,
|
||||
@@ -689,6 +702,7 @@ async fn add_parts(
|
||||
mime_parser: &mut MimeMessage,
|
||||
imf_raw: &[u8],
|
||||
to_ids: &[ContactId],
|
||||
past_ids: &[ContactId],
|
||||
rfc724_mid: &str,
|
||||
from_id: ContactId,
|
||||
seen: bool,
|
||||
@@ -778,6 +792,11 @@ async fn add_parts(
|
||||
}
|
||||
}
|
||||
|
||||
if chat_id.is_none() && is_mdn {
|
||||
chat_id = Some(DC_CHAT_ID_TRASH);
|
||||
info!(context, "Message is an MDN (TRASH).",);
|
||||
}
|
||||
|
||||
if mime_parser.incoming {
|
||||
to_id = ContactId::SELF;
|
||||
|
||||
@@ -789,11 +808,6 @@ async fn add_parts(
|
||||
markseen_on_imap_table(context, rfc724_mid).await.ok();
|
||||
}
|
||||
|
||||
if chat_id.is_none() && is_mdn {
|
||||
chat_id = Some(DC_CHAT_ID_TRASH);
|
||||
info!(context, "Message is an MDN (TRASH).",);
|
||||
}
|
||||
|
||||
let create_blocked_default = if is_bot {
|
||||
Blocked::Not
|
||||
} else {
|
||||
@@ -836,6 +850,7 @@ async fn add_parts(
|
||||
create_blocked,
|
||||
from_id,
|
||||
to_ids,
|
||||
past_ids,
|
||||
&verified_encryption,
|
||||
&grpid,
|
||||
)
|
||||
@@ -906,7 +921,7 @@ async fn add_parts(
|
||||
group_chat_id,
|
||||
from_id,
|
||||
to_ids,
|
||||
is_partial_download.is_some(),
|
||||
past_ids,
|
||||
&verified_encryption,
|
||||
)
|
||||
.await?;
|
||||
@@ -946,14 +961,11 @@ async fn add_parts(
|
||||
chat_id = Some(chat.id);
|
||||
chat_id_blocked = chat.blocked;
|
||||
} else if allow_creation {
|
||||
if let Ok(chat) = ChatIdBlocked::get_for_contact(context, from_id, create_blocked)
|
||||
let chat = ChatIdBlocked::get_for_contact(context, from_id, create_blocked)
|
||||
.await
|
||||
.context("Failed to get (new) chat for contact")
|
||||
.log_err(context)
|
||||
{
|
||||
chat_id = Some(chat.id);
|
||||
chat_id_blocked = chat.blocked;
|
||||
}
|
||||
.context("Failed to get (new) chat for contact")?;
|
||||
chat_id = Some(chat.id);
|
||||
chat_id_blocked = chat.blocked;
|
||||
}
|
||||
|
||||
if let Some(chat_id) = chat_id {
|
||||
@@ -977,7 +989,6 @@ async fn add_parts(
|
||||
// the 1:1 chat accordingly.
|
||||
let chat = match is_partial_download.is_none()
|
||||
&& mime_parser.get_header(HeaderDef::SecureJoin).is_none()
|
||||
&& !is_mdn
|
||||
{
|
||||
true => Some(Chat::load_from_db(context, chat_id).await?)
|
||||
.filter(|chat| chat.typ == Chattype::Single),
|
||||
@@ -1038,9 +1049,13 @@ async fn add_parts(
|
||||
// the mail is on the IMAP server, probably it is also delivered.
|
||||
// We cannot recreate other states (read, error).
|
||||
state = MessageState::OutDelivered;
|
||||
to_id = to_ids.first().copied().unwrap_or_default();
|
||||
to_id = to_ids.first().copied().unwrap_or(ContactId::SELF);
|
||||
|
||||
let self_sent = to_ids.len() == 1 && to_ids.contains(&ContactId::SELF);
|
||||
// Older Delta Chat versions with core <=1.152.2 only accepted
|
||||
// self-sent messages in Saved Messages with own address in the `To` field.
|
||||
// New Delta Chat versions may use empty `To` field
|
||||
// with only a single `hidden-recipients` group in this case.
|
||||
let self_sent = to_ids.len() <= 1 && to_id == ContactId::SELF;
|
||||
|
||||
if mime_parser.sync_items.is_some() && self_sent {
|
||||
chat_id = Some(DC_CHAT_ID_TRASH);
|
||||
@@ -1075,6 +1090,7 @@ async fn add_parts(
|
||||
Blocked::Not,
|
||||
from_id,
|
||||
to_ids,
|
||||
past_ids,
|
||||
&verified_encryption,
|
||||
&grpid,
|
||||
)
|
||||
@@ -1146,9 +1162,8 @@ async fn add_parts(
|
||||
chat_id = Some(id);
|
||||
chat_id_blocked = blocked;
|
||||
}
|
||||
} else if let Ok(chat) =
|
||||
ChatIdBlocked::get_for_contact(context, to_id, Blocked::Not).await
|
||||
{
|
||||
} else {
|
||||
let chat = ChatIdBlocked::get_for_contact(context, to_id, Blocked::Not).await?;
|
||||
chat_id = Some(chat.id);
|
||||
chat_id_blocked = chat.blocked;
|
||||
}
|
||||
@@ -1164,7 +1179,7 @@ async fn add_parts(
|
||||
if chat_id_blocked != Blocked::Not {
|
||||
if let Some(chat_id) = chat_id {
|
||||
chat_id.unblock_ex(context, Nosync).await?;
|
||||
chat_id_blocked = Blocked::Not;
|
||||
// Not assigning `chat_id_blocked = Blocked::Not` to avoid unused_assignments warning.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1176,32 +1191,12 @@ async fn add_parts(
|
||||
chat_id,
|
||||
from_id,
|
||||
to_ids,
|
||||
is_partial_download.is_some(),
|
||||
past_ids,
|
||||
&verified_encryption,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if chat_id.is_none() && self_sent {
|
||||
// from_id==to_id==ContactId::SELF - this is a self-sent messages,
|
||||
// maybe an Autocrypt Setup Message
|
||||
if let Ok(chat) = ChatIdBlocked::get_for_contact(context, ContactId::SELF, Blocked::Not)
|
||||
.await
|
||||
.context("Failed to get (new) chat for contact")
|
||||
.log_err(context)
|
||||
{
|
||||
chat_id = Some(chat.id);
|
||||
chat_id_blocked = chat.blocked;
|
||||
}
|
||||
|
||||
if let Some(chat_id) = chat_id {
|
||||
if Blocked::Not != chat_id_blocked {
|
||||
chat_id.unblock_ex(context, Nosync).await?;
|
||||
// Not assigning `chat_id_blocked = Blocked::Not` to avoid unused_assignments warning.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if chat_id.is_none() {
|
||||
// Check if the message belongs to a broadcast list.
|
||||
if let Some(mailinglist_header) = mime_parser.get_mailinglist_header() {
|
||||
@@ -1217,6 +1212,21 @@ async fn add_parts(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if chat_id.is_none() && self_sent {
|
||||
// from_id==to_id==ContactId::SELF - this is a self-sent messages,
|
||||
// maybe an Autocrypt Setup Message
|
||||
let chat = ChatIdBlocked::get_for_contact(context, ContactId::SELF, Blocked::Not)
|
||||
.await
|
||||
.context("Failed to get (new) chat for contact")?;
|
||||
|
||||
chat_id = Some(chat.id);
|
||||
// Not assigning `chat_id_blocked = chat.blocked` to avoid unused_assignments warning.
|
||||
|
||||
if Blocked::Not != chat.blocked {
|
||||
chat.id.unblock_ex(context, Nosync).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fetching_existing_messages && mime_parser.decrypting_failed {
|
||||
@@ -1236,7 +1246,7 @@ async fn add_parts(
|
||||
}
|
||||
|
||||
let orig_chat_id = chat_id;
|
||||
let mut chat_id = if is_mdn || is_reaction {
|
||||
let mut chat_id = if is_reaction {
|
||||
DC_CHAT_ID_TRASH
|
||||
} else {
|
||||
chat_id.unwrap_or_else(|| {
|
||||
@@ -1694,7 +1704,7 @@ RETURNING id
|
||||
"Message has {icnt} parts and is assigned to chat #{chat_id}."
|
||||
);
|
||||
|
||||
if !is_mdn {
|
||||
if !chat_id.is_trash() {
|
||||
let mut chat = Chat::load_from_db(context, chat_id).await?;
|
||||
|
||||
// In contrast to most other update-timestamps,
|
||||
@@ -1877,34 +1887,44 @@ async fn lookup_chat_or_create_adhoc_group(
|
||||
if !contact_ids.contains(&from_id) {
|
||||
contact_ids.push(from_id);
|
||||
}
|
||||
if let Some((chat_id, blocked)) = context
|
||||
.sql
|
||||
.query_row_optional(
|
||||
&format!(
|
||||
let trans_fn = |t: &mut rusqlite::Transaction| {
|
||||
t.pragma_update(None, "query_only", "0")?;
|
||||
t.execute(
|
||||
"CREATE TEMP TABLE temp.contacts (
|
||||
id INTEGER PRIMARY KEY
|
||||
) STRICT",
|
||||
(),
|
||||
)?;
|
||||
let mut stmt = t.prepare("INSERT INTO temp.contacts(id) VALUES (?)")?;
|
||||
for &id in &contact_ids {
|
||||
stmt.execute((id,))?;
|
||||
}
|
||||
let val = t
|
||||
.query_row(
|
||||
"SELECT c.id, c.blocked
|
||||
FROM chats c INNER JOIN msgs m ON c.id=m.chat_id
|
||||
WHERE m.hidden=0 AND c.grpid='' AND c.name=?
|
||||
AND (SELECT COUNT(*) FROM chats_contacts
|
||||
WHERE chat_id=c.id)=?
|
||||
WHERE chat_id=c.id
|
||||
AND add_timestamp >= remove_timestamp)=?
|
||||
AND (SELECT COUNT(*) FROM chats_contacts
|
||||
WHERE chat_id=c.id
|
||||
AND contact_id NOT IN ({}))=0
|
||||
WHERE chat_id=c.id
|
||||
AND contact_id NOT IN (SELECT id FROM temp.contacts)
|
||||
AND add_timestamp >= remove_timestamp)=0
|
||||
ORDER BY m.timestamp DESC",
|
||||
sql::repeat_vars(contact_ids.len()),
|
||||
),
|
||||
rusqlite::params_from_iter(
|
||||
params_iter(&[&grpname])
|
||||
.chain(params_iter(&[contact_ids.len()]))
|
||||
.chain(params_iter(&contact_ids)),
|
||||
),
|
||||
|row| {
|
||||
let id: ChatId = row.get(0)?;
|
||||
let blocked: Blocked = row.get(1)?;
|
||||
Ok((id, blocked))
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
(&grpname, contact_ids.len()),
|
||||
|row| {
|
||||
let id: ChatId = row.get(0)?;
|
||||
let blocked: Blocked = row.get(1)?;
|
||||
Ok((id, blocked))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
t.execute("DROP TABLE temp.contacts", ())?;
|
||||
Ok(val)
|
||||
};
|
||||
let query_only = true;
|
||||
if let Some((chat_id, blocked)) = context.sql.transaction_ex(query_only, trans_fn).await? {
|
||||
info!(
|
||||
context,
|
||||
"Assigning message to ad-hoc group {chat_id} with matching name and members."
|
||||
@@ -1976,6 +1996,7 @@ async fn create_group(
|
||||
create_blocked: Blocked,
|
||||
from_id: ContactId,
|
||||
to_ids: &[ContactId],
|
||||
past_ids: &[ContactId],
|
||||
verified_encryption: &VerifiedEncryption,
|
||||
grpid: &str,
|
||||
) -> Result<Option<(ChatId, Blocked)>> {
|
||||
@@ -2049,14 +2070,37 @@ async fn create_group(
|
||||
chat_id_blocked = create_blocked;
|
||||
|
||||
// Create initial member list.
|
||||
let mut members = vec![ContactId::SELF];
|
||||
if !from_id.is_special() {
|
||||
members.push(from_id);
|
||||
if let Some(mut chat_group_member_timestamps) = mime_parser.chat_group_member_timestamps() {
|
||||
let mut new_to_ids = to_ids.to_vec();
|
||||
if !new_to_ids.contains(&from_id) {
|
||||
new_to_ids.insert(0, from_id);
|
||||
chat_group_member_timestamps.insert(0, mime_parser.timestamp_sent);
|
||||
}
|
||||
|
||||
update_chats_contacts_timestamps(
|
||||
context,
|
||||
new_chat_id,
|
||||
None,
|
||||
&new_to_ids,
|
||||
past_ids,
|
||||
&chat_group_member_timestamps,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
let mut members = vec![ContactId::SELF];
|
||||
if !from_id.is_special() {
|
||||
members.push(from_id);
|
||||
}
|
||||
members.extend(to_ids);
|
||||
|
||||
chat::add_to_chat_contacts_table(
|
||||
context,
|
||||
mime_parser.timestamp_sent,
|
||||
new_chat_id,
|
||||
&members,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
members.extend(to_ids);
|
||||
members.sort_unstable();
|
||||
members.dedup();
|
||||
chat::add_to_chat_contacts_table(context, new_chat_id, &members).await?;
|
||||
|
||||
context.emit_event(EventType::ChatModified(new_chat_id));
|
||||
chatlist_events::emit_chatlist_changed(context);
|
||||
@@ -2081,13 +2125,86 @@ async fn create_group(
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_chats_contacts_timestamps(
|
||||
context: &Context,
|
||||
chat_id: ChatId,
|
||||
ignored_id: Option<ContactId>,
|
||||
to_ids: &[ContactId],
|
||||
past_ids: &[ContactId],
|
||||
chat_group_member_timestamps: &[i64],
|
||||
) -> Result<bool> {
|
||||
let expected_timestamps_count = to_ids.len() + past_ids.len();
|
||||
|
||||
if chat_group_member_timestamps.len() != expected_timestamps_count {
|
||||
warn!(
|
||||
context,
|
||||
"Chat-Group-Member-Timestamps has wrong number of timestamps, got {}, expected {}.",
|
||||
chat_group_member_timestamps.len(),
|
||||
expected_timestamps_count
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut modified = false;
|
||||
|
||||
context
|
||||
.sql
|
||||
.transaction(|transaction| {
|
||||
let mut add_statement = transaction.prepare(
|
||||
"INSERT INTO chats_contacts (chat_id, contact_id, add_timestamp)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT (chat_id, contact_id)
|
||||
DO
|
||||
UPDATE SET add_timestamp=?3
|
||||
WHERE ?3>add_timestamp AND ?3>=remove_timestamp",
|
||||
)?;
|
||||
|
||||
for (contact_id, ts) in iter::zip(
|
||||
to_ids.iter(),
|
||||
chat_group_member_timestamps.iter().take(to_ids.len()),
|
||||
) {
|
||||
if Some(*contact_id) != ignored_id {
|
||||
// It could be that member was already added,
|
||||
// but updated addition timestamp
|
||||
// is also a modification worth notifying about.
|
||||
modified |= add_statement.execute((chat_id, contact_id, ts))? > 0;
|
||||
}
|
||||
}
|
||||
|
||||
let mut remove_statement = transaction.prepare(
|
||||
"INSERT INTO chats_contacts (chat_id, contact_id, remove_timestamp)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT (chat_id, contact_id)
|
||||
DO
|
||||
UPDATE SET remove_timestamp=?3
|
||||
WHERE ?3>remove_timestamp AND ?3>add_timestamp",
|
||||
)?;
|
||||
|
||||
for (contact_id, ts) in iter::zip(
|
||||
past_ids.iter(),
|
||||
chat_group_member_timestamps.iter().skip(to_ids.len()),
|
||||
) {
|
||||
// It could be that member was already removed,
|
||||
// but updated removal timestamp
|
||||
// is also a modification worth notifying about.
|
||||
modified |= remove_statement.execute((chat_id, contact_id, ts))? > 0;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(modified)
|
||||
}
|
||||
|
||||
/// Apply group member list, name, avatar and protection status changes from the MIME message.
|
||||
///
|
||||
/// Returns `Vec` of group changes messages and, optionally, a better message to replace the
|
||||
/// original system message. If the better message is empty, the original system message should be
|
||||
/// just omitted.
|
||||
/// original system message. If the better message is empty, the original system message
|
||||
/// should be trashed.
|
||||
///
|
||||
/// * `is_partial_download` - whether the message is not fully downloaded.
|
||||
/// * `to_ids` - contents of the `To` and `Cc` headers.
|
||||
/// * `past_ids` - contents of the `Chat-Group-Past-Members` header.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn apply_group_changes(
|
||||
context: &Context,
|
||||
@@ -2095,7 +2212,7 @@ async fn apply_group_changes(
|
||||
chat_id: ChatId,
|
||||
from_id: ContactId,
|
||||
to_ids: &[ContactId],
|
||||
is_partial_download: bool,
|
||||
past_ids: &[ContactId],
|
||||
verified_encryption: &VerifiedEncryption,
|
||||
) -> Result<(Vec<String>, Option<String>)> {
|
||||
if chat_id.is_special() {
|
||||
@@ -2124,49 +2241,6 @@ async fn apply_group_changes(
|
||||
HashSet::<ContactId>::from_iter(chat::get_chat_contacts(context, chat_id).await?);
|
||||
let is_from_in_chat =
|
||||
!chat_contacts.contains(&ContactId::SELF) || chat_contacts.contains(&from_id);
|
||||
// Reject group membership changes from non-members and old changes.
|
||||
let member_list_ts = match !is_partial_download && is_from_in_chat {
|
||||
true => Some(chat_id.get_member_list_timestamp(context).await?),
|
||||
false => None,
|
||||
};
|
||||
// When we remove a member locally, we shift `MemberListTimestamp` by `TIMESTAMP_SENT_TOLERANCE`
|
||||
// into the future, so add some more tolerance here to allow remote membership changes as well.
|
||||
let timestamp_sent_tolerance = constants::TIMESTAMP_SENT_TOLERANCE * 2;
|
||||
let allow_member_list_changes = member_list_ts
|
||||
.filter(|t| {
|
||||
*t <= mime_parser
|
||||
.timestamp_sent
|
||||
.saturating_add(timestamp_sent_tolerance)
|
||||
})
|
||||
.is_some();
|
||||
let sync_member_list = member_list_ts
|
||||
.filter(|t| *t <= mime_parser.timestamp_sent)
|
||||
.is_some();
|
||||
// Whether to rebuild the member list from scratch.
|
||||
let recreate_member_list = {
|
||||
// Always recreate membership list if SELF has been added. The older versions of DC
|
||||
// don't always set "In-Reply-To" to the latest message they sent, but to the latest
|
||||
// delivered message (so it's a race), so we have this heuristic here.
|
||||
self_added
|
||||
|| match mime_parser.get_header(HeaderDef::InReplyTo) {
|
||||
// If we don't know the referenced message, we missed some messages.
|
||||
// Maybe they added/removed members, so we need to recreate our member list.
|
||||
Some(reply_to) => rfc724_mid_exists_ex(context, reply_to, "download_state=0")
|
||||
.await?
|
||||
.filter(|(_, _, downloaded)| *downloaded)
|
||||
.is_none(),
|
||||
None => false,
|
||||
}
|
||||
} && (
|
||||
// Don't allow the timestamp tolerance here for more reliable leaving of groups.
|
||||
sync_member_list || {
|
||||
info!(
|
||||
context,
|
||||
"Ignoring a try to recreate member list of {chat_id} by {from_id}.",
|
||||
);
|
||||
false
|
||||
}
|
||||
);
|
||||
|
||||
if mime_parser.get_header(HeaderDef::ChatVerified).is_some() {
|
||||
if let VerifiedEncryption::NotVerified(err) = verified_encryption {
|
||||
@@ -2190,44 +2264,24 @@ async fn apply_group_changes(
|
||||
if let Some(removed_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved) {
|
||||
removed_id = Contact::lookup_id_by_addr(context, removed_addr, Origin::Unknown).await?;
|
||||
if let Some(id) = removed_id {
|
||||
if allow_member_list_changes && chat_contacts.contains(&id) {
|
||||
better_msg = if id == from_id {
|
||||
Some(stock_str::msg_group_left_local(context, from_id).await)
|
||||
} else {
|
||||
Some(stock_str::msg_del_member_local(context, removed_addr, from_id).await)
|
||||
};
|
||||
}
|
||||
better_msg = if id == from_id {
|
||||
Some(stock_str::msg_group_left_local(context, from_id).await)
|
||||
} else {
|
||||
Some(stock_str::msg_del_member_local(context, removed_addr, from_id).await)
|
||||
};
|
||||
} else {
|
||||
warn!(context, "Removed {removed_addr:?} has no contact id.")
|
||||
}
|
||||
better_msg.get_or_insert_with(Default::default);
|
||||
if !allow_member_list_changes {
|
||||
info!(
|
||||
context,
|
||||
"Ignoring removal of {removed_addr:?} from {chat_id}."
|
||||
);
|
||||
}
|
||||
} else if let Some(added_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberAdded) {
|
||||
if allow_member_list_changes {
|
||||
let is_new_member;
|
||||
if let Some(contact_id) =
|
||||
Contact::lookup_id_by_addr(context, added_addr, Origin::Unknown).await?
|
||||
{
|
||||
added_id = Some(contact_id);
|
||||
is_new_member = !chat_contacts.contains(&contact_id);
|
||||
} else {
|
||||
warn!(context, "Added {added_addr:?} has no contact id.");
|
||||
is_new_member = false;
|
||||
}
|
||||
|
||||
if is_new_member || self_added {
|
||||
better_msg =
|
||||
Some(stock_str::msg_add_member_local(context, added_addr, from_id).await);
|
||||
}
|
||||
if let Some(contact_id) =
|
||||
Contact::lookup_id_by_addr(context, added_addr, Origin::Unknown).await?
|
||||
{
|
||||
added_id = Some(contact_id);
|
||||
} else {
|
||||
info!(context, "Ignoring addition of {added_addr:?} to {chat_id}.");
|
||||
warn!(context, "Added {added_addr:?} has no contact id.");
|
||||
}
|
||||
better_msg.get_or_insert_with(Default::default);
|
||||
|
||||
better_msg = Some(stock_str::msg_add_member_local(context, added_addr, from_id).await);
|
||||
} else if let Some(old_name) = mime_parser
|
||||
.get_header(HeaderDef::ChatGroupNameChanged)
|
||||
.map(|s| s.trim())
|
||||
@@ -2274,113 +2328,114 @@ async fn apply_group_changes(
|
||||
}
|
||||
}
|
||||
|
||||
if allow_member_list_changes {
|
||||
let mut new_members = HashSet::from_iter(to_ids.iter().copied());
|
||||
new_members.insert(ContactId::SELF);
|
||||
if !from_id.is_special() {
|
||||
new_members.insert(from_id);
|
||||
}
|
||||
// These are for adding info messages about implicit membership changes, so they are only
|
||||
// filled when such messages are needed.
|
||||
let mut added_ids = HashSet::<ContactId>::new();
|
||||
let mut removed_ids = HashSet::<ContactId>::new();
|
||||
|
||||
// These are for adding info messages about implicit membership changes, so they are only
|
||||
// filled when such messages are needed.
|
||||
let mut added_ids = HashSet::<ContactId>::new();
|
||||
let mut removed_ids = HashSet::<ContactId>::new();
|
||||
|
||||
if !recreate_member_list {
|
||||
if sync_member_list {
|
||||
added_ids = new_members.difference(&chat_contacts).copied().collect();
|
||||
} else if let Some(added_id) = added_id {
|
||||
added_ids.insert(added_id);
|
||||
}
|
||||
new_members.clone_from(&chat_contacts);
|
||||
// Don't delete any members locally, but instead add absent ones to provide group
|
||||
// membership consistency for all members:
|
||||
// - Classical MUA users usually don't intend to remove users from an email thread, so
|
||||
// if they removed a recipient then it was probably by accident.
|
||||
// - DC users could miss new member additions and then better to handle this in the same
|
||||
// way as for classical MUA messages. Moreover, if we remove a member implicitly, they
|
||||
// will never know that and continue to think they're still here.
|
||||
// But it shouldn't be a big problem if somebody missed a member removal, because they
|
||||
// will likely recreate the member list from the next received message. The problem
|
||||
// occurs only if that "somebody" managed to reply earlier. Really, it's a problem for
|
||||
// big groups with high message rate, but let it be for now.
|
||||
new_members.extend(added_ids.clone());
|
||||
}
|
||||
if let Some(removed_id) = removed_id {
|
||||
new_members.remove(&removed_id);
|
||||
}
|
||||
if recreate_member_list {
|
||||
if self_added {
|
||||
// ... then `better_msg` is already set.
|
||||
} else if chat.blocked == Blocked::Request || !chat_contacts.contains(&ContactId::SELF)
|
||||
{
|
||||
warn!(context, "Implicit addition of SELF to chat {chat_id}.");
|
||||
group_changes_msgs.push(
|
||||
stock_str::msg_add_member_local(
|
||||
context,
|
||||
&context.get_primary_self_addr().await?,
|
||||
ContactId::UNDEFINED,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
} else {
|
||||
added_ids = new_members.difference(&chat_contacts).copied().collect();
|
||||
removed_ids = chat_contacts.difference(&new_members).copied().collect();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(added_id) = added_id {
|
||||
added_ids.remove(&added_id);
|
||||
}
|
||||
if let Some(removed_id) = removed_id {
|
||||
removed_ids.remove(&removed_id);
|
||||
}
|
||||
if !added_ids.is_empty() {
|
||||
warn!(
|
||||
if is_from_in_chat {
|
||||
if let Some(ref chat_group_member_timestamps) = mime_parser.chat_group_member_timestamps() {
|
||||
send_event_chat_modified |= update_chats_contacts_timestamps(
|
||||
context,
|
||||
"Implicit addition of {added_ids:?} to chat {chat_id}."
|
||||
chat_id,
|
||||
Some(from_id),
|
||||
to_ids,
|
||||
past_ids,
|
||||
chat_group_member_timestamps,
|
||||
)
|
||||
.await?;
|
||||
let new_chat_contacts = HashSet::<ContactId>::from_iter(
|
||||
chat::get_chat_contacts(context, chat_id)
|
||||
.await?
|
||||
.iter()
|
||||
.copied(),
|
||||
);
|
||||
}
|
||||
if !removed_ids.is_empty() {
|
||||
warn!(
|
||||
context,
|
||||
"Implicit removal of {removed_ids:?} from chat {chat_id}."
|
||||
);
|
||||
}
|
||||
group_changes_msgs.reserve(added_ids.len() + removed_ids.len());
|
||||
for contact_id in added_ids {
|
||||
let contact = Contact::get_by_id(context, contact_id).await?;
|
||||
group_changes_msgs.push(
|
||||
stock_str::msg_add_member_local(context, contact.get_addr(), ContactId::UNDEFINED)
|
||||
.await,
|
||||
);
|
||||
}
|
||||
for contact_id in removed_ids {
|
||||
let contact = Contact::get_by_id(context, contact_id).await?;
|
||||
group_changes_msgs.push(
|
||||
stock_str::msg_del_member_local(context, contact.get_addr(), ContactId::UNDEFINED)
|
||||
.await,
|
||||
);
|
||||
}
|
||||
|
||||
if new_members != chat_contacts {
|
||||
chat::update_chat_contacts_table(context, chat_id, &new_members).await?;
|
||||
chat_contacts = new_members;
|
||||
send_event_chat_modified = true;
|
||||
}
|
||||
if sync_member_list {
|
||||
let mut ts = mime_parser.timestamp_sent;
|
||||
if recreate_member_list {
|
||||
// Reject all older membership changes. See `allow_member_list_changes` to know how
|
||||
// this works.
|
||||
ts += timestamp_sent_tolerance;
|
||||
added_ids = new_chat_contacts
|
||||
.difference(&chat_contacts)
|
||||
.copied()
|
||||
.collect();
|
||||
removed_ids = chat_contacts
|
||||
.difference(&new_chat_contacts)
|
||||
.copied()
|
||||
.collect();
|
||||
} else {
|
||||
let mut new_members = HashSet::from_iter(to_ids.iter().copied());
|
||||
new_members.insert(ContactId::SELF);
|
||||
if !from_id.is_special() {
|
||||
new_members.insert(from_id);
|
||||
}
|
||||
chat_id
|
||||
.update_timestamp(context, Param::MemberListTimestamp, ts)
|
||||
|
||||
if !self_added {
|
||||
if mime_parser.get_header(HeaderDef::ChatVersion).is_none() {
|
||||
// Allow non-Delta Chat MUAs to add members.
|
||||
added_ids = new_members.difference(&chat_contacts).copied().collect();
|
||||
}
|
||||
|
||||
if let Some(added_id) = added_id {
|
||||
added_ids.insert(added_id);
|
||||
}
|
||||
new_members.clone_from(&chat_contacts);
|
||||
// Don't delete any members locally, but instead add absent ones to provide group
|
||||
// membership consistency for all members:
|
||||
new_members.extend(added_ids.clone());
|
||||
}
|
||||
if let Some(removed_id) = removed_id {
|
||||
new_members.remove(&removed_id);
|
||||
}
|
||||
|
||||
if new_members != chat_contacts {
|
||||
chat::update_chat_contacts_table(
|
||||
context,
|
||||
mime_parser.timestamp_sent,
|
||||
chat_id,
|
||||
&new_members,
|
||||
)
|
||||
.await?;
|
||||
chat_contacts = new_members;
|
||||
send_event_chat_modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(added_id) = added_id {
|
||||
if !added_ids.remove(&added_id) && !self_added {
|
||||
// No-op "Member added" message.
|
||||
//
|
||||
// Trash it.
|
||||
better_msg = Some(String::new());
|
||||
}
|
||||
}
|
||||
if let Some(removed_id) = removed_id {
|
||||
removed_ids.remove(&removed_id);
|
||||
}
|
||||
if !added_ids.is_empty() {
|
||||
warn!(
|
||||
context,
|
||||
"Implicit addition of {added_ids:?} to chat {chat_id}."
|
||||
);
|
||||
}
|
||||
if !removed_ids.is_empty() {
|
||||
warn!(
|
||||
context,
|
||||
"Implicit removal of {removed_ids:?} from chat {chat_id}."
|
||||
);
|
||||
}
|
||||
group_changes_msgs.reserve(added_ids.len() + removed_ids.len());
|
||||
for contact_id in added_ids {
|
||||
let contact = Contact::get_by_id(context, contact_id).await?;
|
||||
group_changes_msgs.push(
|
||||
stock_str::msg_add_member_local(context, contact.get_addr(), ContactId::UNDEFINED)
|
||||
.await,
|
||||
);
|
||||
}
|
||||
for contact_id in removed_ids {
|
||||
let contact = Contact::get_by_id(context, contact_id).await?;
|
||||
group_changes_msgs.push(
|
||||
stock_str::msg_del_member_local(context, contact.get_addr(), ContactId::UNDEFINED)
|
||||
.await,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(avatar_action) = &mime_parser.group_avatar {
|
||||
if !chat_contacts.contains(&ContactId::SELF) {
|
||||
warn!(
|
||||
@@ -2487,7 +2542,13 @@ async fn create_or_lookup_mailinglist(
|
||||
)
|
||||
})?;
|
||||
|
||||
chat::add_to_chat_contacts_table(context, chat_id, &[ContactId::SELF]).await?;
|
||||
chat::add_to_chat_contacts_table(
|
||||
context,
|
||||
mime_parser.timestamp_sent,
|
||||
chat_id,
|
||||
&[ContactId::SELF],
|
||||
)
|
||||
.await?;
|
||||
Ok(Some((chat_id, blocked)))
|
||||
} else {
|
||||
info!(context, "Creating list forbidden by caller.");
|
||||
@@ -2683,7 +2744,13 @@ async fn create_adhoc_group(
|
||||
context,
|
||||
"Created ad-hoc group id={new_chat_id}, name={grpname:?}."
|
||||
);
|
||||
chat::add_to_chat_contacts_table(context, new_chat_id, &member_ids).await?;
|
||||
chat::add_to_chat_contacts_table(
|
||||
context,
|
||||
mime_parser.timestamp_sent,
|
||||
new_chat_id,
|
||||
&member_ids,
|
||||
)
|
||||
.await?;
|
||||
|
||||
context.emit_event(EventType::ChatModified(new_chat_id));
|
||||
chatlist_events::emit_chatlist_changed(context);
|
||||
@@ -2816,38 +2883,27 @@ async fn mark_recipients_as_verified(
|
||||
to_ids: Vec<ContactId>,
|
||||
mimeparser: &MimeMessage,
|
||||
) -> Result<()> {
|
||||
if to_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if mimeparser.get_header(HeaderDef::ChatVerified).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let rows = context
|
||||
.sql
|
||||
.query_map(
|
||||
&format!(
|
||||
"SELECT c.addr, LENGTH(ps.verified_key_fingerprint) FROM contacts c \
|
||||
LEFT JOIN acpeerstates ps ON c.addr=ps.addr WHERE c.id IN({}) ",
|
||||
sql::repeat_vars(to_ids.len())
|
||||
),
|
||||
rusqlite::params_from_iter(&to_ids),
|
||||
|row| {
|
||||
let to_addr: String = row.get(0)?;
|
||||
let is_verified: i32 = row.get(1).unwrap_or(0);
|
||||
Ok((to_addr, is_verified != 0))
|
||||
},
|
||||
|rows| {
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(Into::into)
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let contact = Contact::get_by_id(context, from_id).await?;
|
||||
|
||||
for (to_addr, is_verified) in rows {
|
||||
for id in to_ids {
|
||||
let Some((to_addr, is_verified)) = context
|
||||
.sql
|
||||
.query_row_optional(
|
||||
"SELECT c.addr, LENGTH(ps.verified_key_fingerprint) FROM contacts c
|
||||
LEFT JOIN acpeerstates ps ON c.addr=ps.addr WHERE c.id=?",
|
||||
(id,),
|
||||
|row| {
|
||||
let to_addr: String = row.get(0)?;
|
||||
let is_verified: i32 = row.get(1).unwrap_or(0);
|
||||
Ok((to_addr, is_verified != 0))
|
||||
},
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// mark gossiped keys (if any) as verified
|
||||
if let Some(gossiped_key) = mimeparser.gossiped_keys.get(&to_addr.to_lowercase()) {
|
||||
if let Some(mut peerstate) = Peerstate::from_addr(context, &to_addr).await? {
|
||||
@@ -2945,14 +3001,12 @@ pub(crate) async fn get_prefetch_parent_message(
|
||||
}
|
||||
|
||||
/// Looks up contact IDs from the database given the list of recipients.
|
||||
///
|
||||
/// Returns vector of IDs guaranteed to be unique.
|
||||
async fn add_or_lookup_contacts_by_address_list(
|
||||
context: &Context,
|
||||
address_list: &[SingleInfo],
|
||||
origin: Origin,
|
||||
) -> Result<Vec<ContactId>> {
|
||||
let mut contact_ids = HashSet::new();
|
||||
let mut contact_ids = Vec::new();
|
||||
for info in address_list {
|
||||
let addr = &info.addr;
|
||||
if !may_be_valid_addr(addr) {
|
||||
@@ -2963,13 +3017,13 @@ async fn add_or_lookup_contacts_by_address_list(
|
||||
let (contact_id, _) =
|
||||
Contact::add_or_lookup(context, display_name.unwrap_or_default(), &addr, origin)
|
||||
.await?;
|
||||
contact_ids.insert(contact_id);
|
||||
contact_ids.push(contact_id);
|
||||
} else {
|
||||
warn!(context, "Contact with address {:?} cannot exist.", addr);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(contact_ids.into_iter().collect::<Vec<ContactId>>())
|
||||
Ok(contact_ids)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -566,6 +566,8 @@ async fn test_escaped_recipients() {
|
||||
.unwrap()
|
||||
.0;
|
||||
|
||||
// We test with non-chat message here
|
||||
// because chat messages are not expected to have `Cc` header.
|
||||
receive_imf(
|
||||
&t,
|
||||
b"From: Foobar <foobar@example.com>\n\
|
||||
@@ -573,8 +575,6 @@ async fn test_escaped_recipients() {
|
||||
Cc: =?utf-8?q?=3Ch2=3E?= <carl@host.tld>\n\
|
||||
Subject: foo\n\
|
||||
Message-ID: <asdklfjjaweofi@example.com>\n\
|
||||
Chat-Version: 1.0\n\
|
||||
Chat-Disposition-Notification-To: <foobar@example.com>\n\
|
||||
Date: Sun, 22 Mar 2020 22:37:57 +0000\n\
|
||||
\n\
|
||||
hello\n",
|
||||
@@ -590,11 +590,12 @@ async fn test_escaped_recipients() {
|
||||
let msg = Message::load_from_db(&t, chats.get_msg_id(0).unwrap().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.is_dc_message, MessengerMessage::Yes);
|
||||
assert_eq!(msg.text, "hello");
|
||||
assert_eq!(msg.param.get_int(Param::WantsMdn).unwrap(), 1);
|
||||
assert_eq!(msg.is_dc_message, MessengerMessage::No);
|
||||
assert_eq!(msg.text, "foo – hello");
|
||||
}
|
||||
|
||||
/// Tests that `Cc` header updates display name
|
||||
/// if existing contact has low enough origin.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_cc_to_contact() {
|
||||
let t = TestContext::new_alice().await;
|
||||
@@ -612,6 +613,8 @@ async fn test_cc_to_contact() {
|
||||
.unwrap()
|
||||
.0;
|
||||
|
||||
// We use non-chat message here
|
||||
// because chat messages are not expected to have `Cc` header.
|
||||
receive_imf(
|
||||
&t,
|
||||
b"Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\
|
||||
@@ -620,8 +623,6 @@ async fn test_cc_to_contact() {
|
||||
Cc: Carl <carl@host.tld>\n\
|
||||
Subject: foo\n\
|
||||
Message-ID: <asdklfjjaweofi@example.com>\n\
|
||||
Chat-Version: 1.0\n\
|
||||
Chat-Disposition-Notification-To: <foobar@example.com>\n\
|
||||
Date: Sun, 22 Mar 2020 22:37:57 +0000\n\
|
||||
\n\
|
||||
hello\n",
|
||||
@@ -2200,6 +2201,30 @@ Message content",
|
||||
assert_ne!(msg.chat_id, t.get_self_chat().await.id);
|
||||
}
|
||||
|
||||
/// Tests that message with hidden recipients is assigned to Saved Messages chat.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_hidden_recipients_self_chat() {
|
||||
let t = TestContext::new_alice().await;
|
||||
|
||||
receive_imf(
|
||||
&t,
|
||||
b"Subject: s
|
||||
Chat-Version: 1.0
|
||||
Message-ID: <foobar@localhost>
|
||||
To: hidden-recipients:;
|
||||
From: <alice@example.org>
|
||||
|
||||
Message content",
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let msg = t.get_last_msg().await;
|
||||
assert_eq!(msg.chat_id, t.get_self_chat().await.id);
|
||||
assert_eq!(msg.to_id, ContactId::SELF);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_no_unencrypted_name_in_self_chat() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
@@ -3309,6 +3334,7 @@ async fn test_outgoing_private_reply_multidevice() -> Result<()> {
|
||||
let group_id = chat::create_group_chat(&bob, ProtectionStatus::Unprotected, "Group").await?;
|
||||
chat::add_to_chat_contacts_table(
|
||||
&bob,
|
||||
time(),
|
||||
group_id,
|
||||
&[
|
||||
bob.add_or_lookup_contact(&alice1).await.id,
|
||||
@@ -3518,26 +3544,27 @@ async fn test_no_private_reply_to_blocked_account() -> Result<()> {
|
||||
let alice = tcm.alice().await;
|
||||
let bob = tcm.bob().await;
|
||||
|
||||
// =============== Bob creates a group ===============
|
||||
tcm.section("Bob creates a group");
|
||||
let group_id = chat::create_group_chat(&bob, ProtectionStatus::Unprotected, "Group").await?;
|
||||
chat::add_to_chat_contacts_table(
|
||||
&bob,
|
||||
time(),
|
||||
group_id,
|
||||
&[bob.add_or_lookup_contact(&alice).await.id],
|
||||
)
|
||||
.await?;
|
||||
|
||||
// =============== Bob sends the first message to the group ===============
|
||||
tcm.section("Bob sends the first message to the group");
|
||||
let sent = bob.send_text(group_id, "Hello all!").await;
|
||||
alice.recv_msg(&sent).await;
|
||||
|
||||
let chats = Chatlist::try_load(&bob, 0, None, None).await?;
|
||||
assert_eq!(chats.len(), 1);
|
||||
|
||||
// =============== Bob blocks Alice ================
|
||||
tcm.section("Bob blocks Alice");
|
||||
Contact::block(&bob, bob.add_or_lookup_contact(&alice).await.id).await?;
|
||||
|
||||
// =============== Alice replies private to Bob ==============
|
||||
tcm.section("Alice replies private to Bob");
|
||||
let received = alice.get_last_msg().await;
|
||||
assert_eq!(received.text, "Hello all!");
|
||||
|
||||
@@ -3551,7 +3578,7 @@ async fn test_no_private_reply_to_blocked_account() -> Result<()> {
|
||||
let sent2 = alice.send_msg(alice_bob_chat.id, &mut msg_out).await;
|
||||
bob.recv_msg(&sent2).await;
|
||||
|
||||
// ========= check that no contact request was created ============
|
||||
// check that no contact request was created
|
||||
let chats = Chatlist::try_load(&bob, 0, None, None).await.unwrap();
|
||||
assert_eq!(chats.len(), 1);
|
||||
let chat_id = chats.get_chat_id(0).unwrap();
|
||||
@@ -3562,7 +3589,7 @@ async fn test_no_private_reply_to_blocked_account() -> Result<()> {
|
||||
let received = bob.get_last_msg().await;
|
||||
assert_eq!(received.text, "Hello all!");
|
||||
|
||||
// =============== Bob unblocks Alice ================
|
||||
tcm.section("Bob unblocks Alice");
|
||||
// test if the blocked chat is restored correctly
|
||||
Contact::unblock(&bob, bob.add_or_lookup_contact(&alice).await.id).await?;
|
||||
let chats = Chatlist::try_load(&bob, 0, None, None).await.unwrap();
|
||||
@@ -4127,11 +4154,15 @@ async fn test_ignore_outdated_membership_changes() -> Result<()> {
|
||||
|
||||
SystemTime::shift(Duration::from_secs(3600));
|
||||
|
||||
// Bob replies again adding Alice back.
|
||||
// Bob replies again, even after some time this does not add Alice back.
|
||||
//
|
||||
// Bob cannot learn from Alice that Alice has left the group
|
||||
// because Alice is not going to send more messages to the group.
|
||||
send_text_msg(bob, bob_chat_id, "i'm bob".to_string()).await?;
|
||||
let msg = &bob.pop_sent_msg().await;
|
||||
alice.recv_msg(msg).await;
|
||||
assert!(is_contact_in_chat(alice, alice_chat_id, ContactId::SELF).await?);
|
||||
|
||||
assert!(!is_contact_in_chat(alice, alice_chat_id, ContactId::SELF).await?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -4192,7 +4223,7 @@ async fn test_dont_recreate_contacts_on_add_remove() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_recreate_contact_list_on_missing_messages() -> Result<()> {
|
||||
async fn test_delayed_removal_is_ignored() -> Result<()> {
|
||||
let alice = TestContext::new_alice().await;
|
||||
let bob = TestContext::new_bob().await;
|
||||
let chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "Group").await?;
|
||||
@@ -4200,6 +4231,7 @@ async fn test_recreate_contact_list_on_missing_messages() -> Result<()> {
|
||||
// create chat with three members
|
||||
add_to_chat_contacts_table(
|
||||
&alice,
|
||||
time(),
|
||||
chat_id,
|
||||
&[
|
||||
Contact::create(&alice, "bob", "bob@example.net").await?,
|
||||
@@ -4212,12 +4244,12 @@ async fn test_recreate_contact_list_on_missing_messages() -> Result<()> {
|
||||
let bob_chat_id = bob.recv_msg(&alice.pop_sent_msg().await).await.chat_id;
|
||||
bob_chat_id.accept(&bob).await?;
|
||||
|
||||
// bob removes a member
|
||||
// Bob removes Fiona.
|
||||
let bob_contact_fiona = Contact::create(&bob, "fiona", "fiona@example.net").await?;
|
||||
remove_contact_from_chat(&bob, bob_chat_id, bob_contact_fiona).await?;
|
||||
let remove_msg = bob.pop_sent_msg().await;
|
||||
|
||||
// bob adds new members
|
||||
// Bob adds new members "blue" and "orange", but first addition message is lost.
|
||||
let bob_blue = Contact::create(&bob, "blue", "blue@example.net").await?;
|
||||
add_contact_to_chat(&bob, bob_chat_id, bob_blue).await?;
|
||||
bob.pop_sent_msg().await;
|
||||
@@ -4225,32 +4257,32 @@ async fn test_recreate_contact_list_on_missing_messages() -> Result<()> {
|
||||
add_contact_to_chat(&bob, bob_chat_id, bob_orange).await?;
|
||||
let add_msg = bob.pop_sent_msg().await;
|
||||
|
||||
// alice only receives the second member addition
|
||||
// Alice only receives the second member addition,
|
||||
// but this results in addition of both members
|
||||
// and removal of Fiona.
|
||||
alice.recv_msg(&add_msg).await;
|
||||
|
||||
// since we missed messages, a new contact list should be build
|
||||
assert_eq!(get_chat_contacts(&alice, chat_id).await?.len(), 4);
|
||||
|
||||
// re-add fiona
|
||||
// Alice re-adds Fiona.
|
||||
add_contact_to_chat(&alice, chat_id, alice_fiona).await?;
|
||||
assert_eq!(get_chat_contacts(&alice, chat_id).await?.len(), 5);
|
||||
|
||||
// delayed removal of fiona shouldn't remove her
|
||||
alice.recv_msg_trash(&remove_msg).await;
|
||||
// Delayed removal of Fiona by Bob shouldn't remove her.
|
||||
alice.recv_msg(&remove_msg).await;
|
||||
assert_eq!(get_chat_contacts(&alice, chat_id).await?.len(), 5);
|
||||
|
||||
alice
|
||||
.golden_test_chat(
|
||||
chat_id,
|
||||
"receive_imf_recreate_contact_list_on_missing_messages",
|
||||
)
|
||||
.golden_test_chat(chat_id, "receive_imf_delayed_removal_is_ignored")
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_dont_readd_with_normal_msg() -> Result<()> {
|
||||
let alice = TestContext::new_alice().await;
|
||||
let bob = TestContext::new_bob().await;
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = tcm.alice().await;
|
||||
let bob = tcm.bob().await;
|
||||
|
||||
let alice_chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "Group").await?;
|
||||
|
||||
@@ -4265,6 +4297,7 @@ async fn test_dont_readd_with_normal_msg() -> Result<()> {
|
||||
let bob_chat_id = bob.recv_msg(&alice.pop_sent_msg().await).await.chat_id;
|
||||
bob_chat_id.accept(&bob).await?;
|
||||
|
||||
// Bob leaves, but Alice didn't receive Bob's leave message.
|
||||
remove_contact_from_chat(&bob, bob_chat_id, ContactId::SELF).await?;
|
||||
bob.pop_sent_msg().await;
|
||||
assert_eq!(get_chat_contacts(&bob, bob_chat_id).await?.len(), 1);
|
||||
@@ -4278,12 +4311,11 @@ async fn test_dont_readd_with_normal_msg() -> Result<()> {
|
||||
.await?;
|
||||
bob.recv_msg(&alice.pop_sent_msg().await).await;
|
||||
|
||||
// Alice didn't receive Bob's leave message although a lot of time has
|
||||
// passed, so Bob must re-add themselves otherwise other members would think
|
||||
// Bob is still here while they aren't. Bob should retry to leave if they
|
||||
// think that Alice didn't re-add them on purpose (which is possible if Alice uses a classical
|
||||
// MUA).
|
||||
assert!(is_contact_in_chat(&bob, bob_chat_id, ContactId::SELF).await?);
|
||||
// Bob received a message from Alice, but this should not re-add him to the group.
|
||||
assert!(!is_contact_in_chat(&bob, bob_chat_id, ContactId::SELF).await?);
|
||||
|
||||
// Bob got an update that fiora is added nevertheless.
|
||||
assert_eq!(get_chat_contacts(&bob, bob_chat_id).await?.len(), 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4511,19 +4543,14 @@ async fn test_recreate_member_list_on_missing_add_of_self() -> Result<()> {
|
||||
bob.recv_msg(&alice.pop_sent_msg().await).await;
|
||||
assert!(!is_contact_in_chat(&bob, bob_chat_id, ContactId::SELF).await?);
|
||||
|
||||
// But if Bob left a long time ago, they must recreate the member list after missing a message.
|
||||
// Even if some time passed, Bob must not be re-added back.
|
||||
SystemTime::shift(Duration::from_secs(3600));
|
||||
send_text_msg(&alice, alice_chat_id, "5th message".to_string()).await?;
|
||||
alice.pop_sent_msg().await;
|
||||
send_text_msg(&alice, alice_chat_id, "6th message".to_string()).await?;
|
||||
bob.recv_msg(&alice.pop_sent_msg().await).await;
|
||||
assert!(is_contact_in_chat(&bob, bob_chat_id, ContactId::SELF).await?);
|
||||
assert!(!is_contact_in_chat(&bob, bob_chat_id, ContactId::SELF).await?);
|
||||
|
||||
bob.golden_test_chat(
|
||||
bob_chat_id,
|
||||
"receive_imf_recreate_member_list_on_missing_add_of_self",
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4757,13 +4784,6 @@ async fn test_partial_group_consistency() -> Result<()> {
|
||||
let contacts = get_chat_contacts(&bob, bob_chat_id).await?;
|
||||
assert_eq!(contacts.len(), 2);
|
||||
|
||||
// Get initial timestamp.
|
||||
let timestamp = bob_chat_id
|
||||
.get_param(&bob)
|
||||
.await?
|
||||
.get_i64(Param::MemberListTimestamp)
|
||||
.unwrap();
|
||||
|
||||
// Bob receives partial message.
|
||||
let msg_id = receive_imf_from_inbox(
|
||||
&bob,
|
||||
@@ -4784,15 +4804,9 @@ Chat-Group-Member-Added: charlie@example.com",
|
||||
.context("no received message")?;
|
||||
|
||||
let msg = Message::load_from_db(&bob, msg_id.msg_ids[0]).await?;
|
||||
let timestamp2 = bob_chat_id
|
||||
.get_param(&bob)
|
||||
.await?
|
||||
.get_i64(Param::MemberListTimestamp)
|
||||
.unwrap();
|
||||
|
||||
// Partial download does not change the member list.
|
||||
assert_eq!(msg.download_state, DownloadState::Available);
|
||||
assert_eq!(timestamp, timestamp2);
|
||||
assert_eq!(get_chat_contacts(&bob, bob_chat_id).await?, contacts);
|
||||
|
||||
// Alice sends normal message to bob, adding fiona.
|
||||
@@ -4805,15 +4819,6 @@ Chat-Group-Member-Added: charlie@example.com",
|
||||
|
||||
bob.recv_msg(&alice.pop_sent_msg().await).await;
|
||||
|
||||
let timestamp3 = bob_chat_id
|
||||
.get_param(&bob)
|
||||
.await?
|
||||
.get_i64(Param::MemberListTimestamp)
|
||||
.unwrap();
|
||||
|
||||
// Receiving a message after a partial download recreates the member list because we treat
|
||||
// such messages as if we have not seen them.
|
||||
assert_ne!(timestamp, timestamp3);
|
||||
let contacts = get_chat_contacts(&bob, bob_chat_id).await?;
|
||||
assert_eq!(contacts.len(), 3);
|
||||
|
||||
@@ -4837,15 +4842,9 @@ Chat-Group-Member-Added: charlie@example.com",
|
||||
.context("no received message")?;
|
||||
|
||||
let msg = Message::load_from_db(&bob, msg_id.msg_ids[0]).await?;
|
||||
let timestamp4 = bob_chat_id
|
||||
.get_param(&bob)
|
||||
.await?
|
||||
.get_i64(Param::MemberListTimestamp)
|
||||
.unwrap();
|
||||
|
||||
// After full download, the old message should not change group state.
|
||||
assert_eq!(msg.download_state, DownloadState::Done);
|
||||
assert_eq!(timestamp3, timestamp4);
|
||||
assert_eq!(get_chat_contacts(&bob, bob_chat_id).await?, contacts);
|
||||
|
||||
Ok(())
|
||||
@@ -4868,19 +4867,13 @@ async fn test_leave_protected_group_missing_member_key() -> Result<()> {
|
||||
("b@b", "bob@example.net"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// We fail to send the message.
|
||||
assert!(remove_contact_from_chat(alice, group_id, ContactId::SELF)
|
||||
.await
|
||||
.is_err());
|
||||
assert!(is_contact_in_chat(alice, group_id, ContactId::SELF).await?);
|
||||
alice
|
||||
.sql
|
||||
.execute(
|
||||
"UPDATE acpeerstates SET addr=? WHERE addr=?",
|
||||
("bob@example.net", "b@b"),
|
||||
)
|
||||
.await?;
|
||||
remove_contact_from_chat(alice, group_id, ContactId::SELF).await?;
|
||||
alice.pop_sent_msg().await;
|
||||
|
||||
// The contact is already removed anyway.
|
||||
assert!(!is_contact_in_chat(alice, group_id, ContactId::SELF).await?);
|
||||
Ok(())
|
||||
}
|
||||
@@ -4902,12 +4895,22 @@ async fn test_protected_group_add_remove_member_missing_key() -> Result<()> {
|
||||
.await?;
|
||||
|
||||
let fiona = &tcm.fiona().await;
|
||||
let fiona_addr = fiona.get_config(Config::Addr).await?.unwrap();
|
||||
mark_as_verified(alice, fiona).await;
|
||||
let alice_fiona_id = alice.add_or_lookup_contact(fiona).await.id;
|
||||
assert!(add_contact_to_chat(alice, group_id, alice_fiona_id)
|
||||
.await
|
||||
.is_err());
|
||||
assert!(!is_contact_in_chat(alice, group_id, alice_fiona_id).await?);
|
||||
// Sending the message failed,
|
||||
// but member is added to the chat locally already.
|
||||
assert!(is_contact_in_chat(alice, group_id, alice_fiona_id).await?);
|
||||
let msg = alice.get_last_msg_in(group_id).await;
|
||||
assert!(msg.is_info());
|
||||
assert_eq!(
|
||||
msg.get_text(),
|
||||
stock_str::msg_add_member_local(alice, &fiona_addr, ContactId::SELF).await
|
||||
);
|
||||
|
||||
// Now the chat has a message "You added member fiona@example.net. [INFO] !!" (with error) that
|
||||
// may be confusing, but if the error is displayed in UIs, it's more or less ok. This is not a
|
||||
// normal scenario anyway.
|
||||
|
||||
@@ -59,8 +59,13 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
|
||||
// only become usable once the protocol is finished.
|
||||
let group_chat_id = state.joining_chat_id(context).await?;
|
||||
if !is_contact_in_chat(context, group_chat_id, invite.contact_id()).await? {
|
||||
chat::add_to_chat_contacts_table(context, group_chat_id, &[invite.contact_id()])
|
||||
.await?;
|
||||
chat::add_to_chat_contacts_table(
|
||||
context,
|
||||
time(),
|
||||
group_chat_id,
|
||||
&[invite.contact_id()],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let msg = stock_str::secure_join_started(context, invite.contact_id()).await;
|
||||
chat::add_info_msg(context, group_chat_id, &msg, time()).await?;
|
||||
|
||||
41
src/sql.rs
41
src/sql.rs
@@ -44,12 +44,6 @@ macro_rules! params_slice {
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn params_iter(
|
||||
iter: &[impl crate::sql::ToSql],
|
||||
) -> impl Iterator<Item = &dyn crate::sql::ToSql> {
|
||||
iter.iter().map(|item| item as &dyn crate::sql::ToSql)
|
||||
}
|
||||
|
||||
mod migrations;
|
||||
mod pool;
|
||||
|
||||
@@ -441,7 +435,7 @@ impl Sql {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Execute the function inside a transaction assuming that it does write queries.
|
||||
/// Execute the function inside a transaction assuming that it does writes.
|
||||
///
|
||||
/// If the function returns an error, the transaction will be rolled back. If it does not return an
|
||||
/// error, the transaction will be committed.
|
||||
@@ -450,7 +444,28 @@ impl Sql {
|
||||
H: Send + 'static,
|
||||
G: Send + FnOnce(&mut rusqlite::Transaction<'_>) -> Result<H>,
|
||||
{
|
||||
self.call_write(move |conn| {
|
||||
let query_only = false;
|
||||
self.transaction_ex(query_only, callback).await
|
||||
}
|
||||
|
||||
/// Execute the function inside a transaction.
|
||||
///
|
||||
/// * `query_only` - Whether the function only executes read statements (queries) and can be run
|
||||
/// in parallel with other transactions. NB: Creating and modifying temporary tables are also
|
||||
/// allowed with `query_only`, temporary tables aren't visible in other connections, but you
|
||||
/// need to pass `PRAGMA query_only=0;` to SQLite before that:
|
||||
/// `pragma_update(None, "query_only", "0")`.
|
||||
/// Also temporary tables need to be dropped because the connection is returned to the pool
|
||||
/// then.
|
||||
///
|
||||
/// If the function returns an error, the transaction will be rolled back. If it does not return
|
||||
/// an error, the transaction will be committed.
|
||||
pub async fn transaction_ex<G, H>(&self, query_only: bool, callback: G) -> Result<H>
|
||||
where
|
||||
H: Send + 'static,
|
||||
G: Send + FnOnce(&mut rusqlite::Transaction<'_>) -> Result<H>,
|
||||
{
|
||||
self.call(query_only, move |conn| {
|
||||
let mut transaction = conn.transaction()?;
|
||||
let ret = callback(&mut transaction);
|
||||
|
||||
@@ -1024,16 +1039,6 @@ async fn prune_tombstones(sql: &Sql) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper function to return comma-separated sequence of `?` chars.
|
||||
///
|
||||
/// Use this together with [`rusqlite::ParamsFromIter`] to use dynamically generated
|
||||
/// parameter lists.
|
||||
pub fn repeat_vars(count: usize) -> String {
|
||||
let mut s = "?,".repeat(count);
|
||||
s.pop(); // Remove trailing comma
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1123,11 +1123,7 @@ CREATE INDEX msgs_status_updates_index2 ON msgs_status_updates (uid);
|
||||
|
||||
inc_and_check(&mut migration_version, 127)?;
|
||||
if dbversion < migration_version {
|
||||
// Existing chatmail configurations having `delete_server_after` disabled should get
|
||||
// `bcc_self` enabled, they may be multidevice configurations because before,
|
||||
// `delete_server_after` was set to 0 upon a backup export for them, but together with this
|
||||
// migration `bcc_self` is enabled instead (whose default is changed to 0 for chatmail). We
|
||||
// don't check `is_chatmail` for simplicity.
|
||||
// This is buggy: `delete_server_after` > 1 isn't handled. Migration #129 fixes this.
|
||||
sql.execute_migration(
|
||||
"INSERT OR IGNORE INTO config (keyname, value)
|
||||
SELECT 'bcc_self', '1'
|
||||
@@ -1138,6 +1134,43 @@ CREATE INDEX msgs_status_updates_index2 ON msgs_status_updates (uid);
|
||||
.await?;
|
||||
}
|
||||
|
||||
inc_and_check(&mut migration_version, 128)?;
|
||||
if dbversion < migration_version {
|
||||
// Add the timestamps of addition and removal.
|
||||
//
|
||||
// If `add_timestamp >= remove_timestamp`,
|
||||
// then the member is currently a member of the chat.
|
||||
// Otherwise the member is a past member.
|
||||
sql.execute_migration(
|
||||
"ALTER TABLE chats_contacts
|
||||
ADD COLUMN add_timestamp NOT NULL DEFAULT 0;
|
||||
ALTER TABLE chats_contacts
|
||||
ADD COLUMN remove_timestamp NOT NULL DEFAULT 0;
|
||||
",
|
||||
migration_version,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
inc_and_check(&mut migration_version, 129)?;
|
||||
if dbversion < migration_version {
|
||||
// Existing chatmail configurations having `delete_server_after` != "delete at once" should
|
||||
// get `bcc_self` enabled, they may be multidevice configurations:
|
||||
// - Before migration #127, `delete_server_after` was set to 0 upon a backup export, but
|
||||
// then `bcc_self` is enabled instead (whose default is changed to 0 for chatmail).
|
||||
// - The user might set `delete_server_after` to a value other than 0 or 1 when that was
|
||||
// possible in UIs.
|
||||
// We don't check `is_chatmail` for simplicity.
|
||||
sql.execute_migration(
|
||||
"INSERT OR IGNORE INTO config (keyname, value)
|
||||
SELECT 'bcc_self', '1'
|
||||
FROM config WHERE keyname='delete_server_after' AND value!='1'
|
||||
",
|
||||
migration_version,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let new_version = sql
|
||||
.get_raw_config_int(VERSION_CFG)
|
||||
.await?
|
||||
@@ -1198,6 +1231,35 @@ impl Sql {
|
||||
.await
|
||||
.with_context(|| format!("execute_migration failed for version {version}"))?;
|
||||
|
||||
self.set_db_version_in_cache(version).await
|
||||
self.config_cache.write().await.clear();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::test_utils::TestContext;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_clear_config_cache() -> anyhow::Result<()> {
|
||||
// Some migrations change the `config` table in SQL.
|
||||
// This test checks that the config cache is invalidated in `execute_migration()`.
|
||||
|
||||
let t = TestContext::new().await;
|
||||
assert_eq!(t.get_config_bool(Config::IsChatmail).await?, false);
|
||||
|
||||
t.sql
|
||||
.execute_migration(
|
||||
"INSERT INTO config (keyname, value) VALUES ('is_chatmail', '1')",
|
||||
1000,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(t.get_config_bool(Config::IsChatmail).await?, true);
|
||||
assert_eq!(t.sql.get_raw_config_int(VERSION_CFG).await?.unwrap(), 1000);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ use crate::pgp::KeyPair;
|
||||
use crate::receive_imf::receive_imf;
|
||||
use crate::securejoin::{get_securejoin_qr, join_securejoin};
|
||||
use crate::stock_str::StockStrings;
|
||||
use crate::tools::time;
|
||||
|
||||
#[allow(non_upper_case_globals)]
|
||||
pub const AVATAR_900x900_BYTES: &[u8] = include_bytes!("../test-data/image/avatar900x900.png");
|
||||
@@ -880,7 +881,7 @@ impl TestContext {
|
||||
let contact = self.add_or_lookup_contact(member).await;
|
||||
to_add.push(contact.id);
|
||||
}
|
||||
add_to_chat_contacts_table(self, chat_id, &to_add)
|
||||
add_to_chat_contacts_table(self, time(), chat_id, &to_add)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -686,7 +686,7 @@ async fn test_break_protection_then_verify_again() -> Result<()> {
|
||||
alice.create_chat(&bob).await;
|
||||
assert_verified(&alice, &bob, ProtectionStatus::Protected).await;
|
||||
let chats = Chatlist::try_load(&alice, DC_GCL_FOR_FORWARDING, None, None).await?;
|
||||
assert!(chats.len() == 1);
|
||||
assert_eq!(chats.len(), 1);
|
||||
|
||||
tcm.section("Bob reinstalls DC");
|
||||
drop(bob);
|
||||
@@ -709,7 +709,7 @@ async fn test_break_protection_then_verify_again() -> Result<()> {
|
||||
assert_eq!(chat.is_protected(), false);
|
||||
assert_eq!(chat.is_protection_broken(), true);
|
||||
let chats = Chatlist::try_load(&alice, DC_GCL_FOR_FORWARDING, None, None).await?;
|
||||
assert!(chats.len() == 1);
|
||||
assert_eq!(chats.len(), 1);
|
||||
|
||||
{
|
||||
let alice_bob_chat = alice.get_chat(&bob_new).await;
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
Group#Chat#10: Group chat [3 member(s)]
|
||||
--------------------------------------------------------------------------------
|
||||
Msg#10: (Contact#Contact#11): I created a group [FRESH]
|
||||
Msg#11: (Contact#Contact#11): Member Fiona (fiona@example.net) added by alice@example.org. [FRESH][INFO]
|
||||
Msg#12: Me (Contact#Contact#Self): You removed member Fiona (fiona@example.net). [INFO] √
|
||||
Msg#13: (Contact#Contact#11): Welcome, Fiona! [FRESH]
|
||||
Msg#14: info (Contact#Contact#Info): Member Fiona (fiona@example.net) added. [NOTICED][INFO]
|
||||
Msg#15: (Contact#Contact#11): Welcome back, Fiona! [FRESH]
|
||||
--------------------------------------------------------------------------------
|
||||
@@ -1,8 +1,7 @@
|
||||
Group#Chat#10: Group chat [4 member(s)]
|
||||
Group#Chat#10: Group chat [3 member(s)]
|
||||
--------------------------------------------------------------------------------
|
||||
Msg#10: (Contact#Contact#10): Hi! I created a group. [FRESH]
|
||||
Msg#11: Me (Contact#Contact#Self): You left the group. [INFO] √
|
||||
Msg#12: (Contact#Contact#10): Member claire@example.net added by alice@example.org. [FRESH][INFO]
|
||||
Msg#13: info (Contact#Contact#Info): Member Me (bob@example.net) added. [NOTICED][INFO]
|
||||
Msg#14: (Contact#Contact#10): What a silence! [FRESH]
|
||||
Msg#13: (Contact#Contact#10): What a silence! [FRESH]
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
@@ -5,4 +5,5 @@ Msg#11: info (Contact#Contact#Info): Member blue@example.net added. [NOTICED][IN
|
||||
Msg#12: info (Contact#Contact#Info): Member fiona (fiona@example.net) removed. [NOTICED][INFO]
|
||||
Msg#13: bob (Contact#Contact#11): Member orange@example.net added by bob (bob@example.net). [FRESH][INFO]
|
||||
Msg#14: Me (Contact#Contact#Self): You added member fiona (fiona@example.net). [INFO] o
|
||||
Msg#15: bob (Contact#Contact#11): Member fiona (fiona@example.net) removed by bob (bob@example.net). [FRESH][INFO]
|
||||
--------------------------------------------------------------------------------
|
||||
@@ -1,9 +0,0 @@
|
||||
Group#Chat#10: Group [2 member(s)]
|
||||
--------------------------------------------------------------------------------
|
||||
Msg#10: info (Contact#Contact#Info): Member Me (bob@example.net) added. [NOTICED][INFO]
|
||||
Msg#11: (Contact#Contact#10): second message [FRESH]
|
||||
Msg#12🔒: Me (Contact#Contact#Self): You left the group. [INFO] √
|
||||
Msg#13: (Contact#Contact#10): 4th message [FRESH]
|
||||
Msg#14: info (Contact#Contact#Info): Member Me (bob@example.net) added. [NOTICED][INFO]
|
||||
Msg#15: (Contact#Contact#10): 6th message [FRESH]
|
||||
--------------------------------------------------------------------------------
|
||||
Reference in New Issue
Block a user