Compare commits

..

2 Commits

Author SHA1 Message Date
Sebastian Klähn
2a4dab9ade fixes 2025-01-15 10:13:34 +01:00
Sebastian Klähn
4479000b71 start test 2025-01-08 10:39:51 +01:00
93 changed files with 11309 additions and 13320 deletions

View File

@@ -24,7 +24,7 @@ jobs:
name: Lint Rust
runs-on: ubuntu-latest
env:
RUSTUP_TOOLCHAIN: 1.84.0
RUSTUP_TOOLCHAIN: 1.83.0
steps:
- uses: actions/checkout@v4
with:
@@ -97,15 +97,15 @@ jobs:
matrix:
include:
- os: ubuntu-latest
rust: 1.84.0
rust: 1.83.0
- os: windows-latest
rust: 1.84.0
rust: 1.83.0
- os: macos-latest
rust: 1.84.0
rust: 1.83.0
# Minimum Supported Rust Version = 1.81.0
# Minimum Supported Rust Version = 1.77.0
- os: ubuntu-latest
rust: 1.81.0
rust: 1.77.0
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
@@ -152,7 +152,7 @@ jobs:
uses: swatinem/rust-cache@v2
- name: Build C library
run: cargo build -p deltachat_ffi
run: cargo build -p deltachat_ffi --features jsonrpc
- 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.8
# Minimum Supported Python Version = 3.7
# This is the minimum version for which manylinux Python wheels are
# built. Test it with minimum supported Rust version.
- os: ubuntu-latest
python: 3.8
python: 3.7
runs-on: ${{ matrix.os }}
steps:
@@ -277,9 +277,9 @@ jobs:
- os: macos-latest
python: pypy3.10
# Minimum Supported Python Version = 3.8
# Minimum Supported Python Version = 3.7
- os: ubuntu-latest
python: 3.8
python: 3.7
runs-on: ${{ matrix.os }}
steps:

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v2.3.0
uses: dependabot/fetch-metadata@v2.2.0
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Approve a PR

View File

@@ -1,177 +1,5 @@
# Changelog
## [1.155.3] - 2025-02-05
### Fixes
- Store device token in IMAP METADATA on each connection.
### Miscellaneous Tasks
- Upgrade iroh from 0.30 to 0.32.
- Update `pgp` to 0.15.
- cargo: Bump thiserror from 1.0.69 to 2.0.9.
- cargo: Bump pin-project from 1.1.7 to 1.1.8.
- cargo: Bump dirs from 5.0.1 to 6.0.0.
- cargo: Bump hyper from 1.5.2 to 1.6.0.
- cargo: Bump webpki-roots from 0.26.7 to 0.26.8.
- cargo: Bump futures-lite from 2.5.0 to 2.6.0.
- Update OpenSSL to fix RUSTSEC-2025-0004.
- cargo: Bump tokio from 1.42.0 to 1.43.0.
- cargo: Bump syn from 2.0.94 to 2.0.98.
- cargo: Bump rustls from 0.23.20 to 0.23.22.
- cargo: Bump data-encoding from 2.6.0 to 2.7.0.
- cargo: Bump serde_json from 1.0.134 to 1.0.138.
- cargo: Bump uuid from 1.11.0 to 1.12.1.
- cargo: Bump log from 0.4.22 to 0.4.25.
- cargo: Bump rustls-pki-types from 1.10.1 to 1.11.0.
- Update futures-concurrency.
### Documentation
- Assign docs to correct object.
### Tests
- Make sure DCBACKUP2 compatibility does not break again.
## [1.155.2] - 2025-01-31
This release accidentally broke compatibility
with previous versions of `DCBACKUP2` QR codes
due to iroh upgrade.
### API-Changes
- Add `IncomingReaction.chat_id` ([#6459](https://github.com/deltachat/deltachat-core-rust/pull/6459)).
### Features / Changes
- Deduplicate blob files in `chat.rs`, `config.rs`, and `integration.rs`.
- Improve logging around IMAP IDLE.
- Upgrade to iroh@0.30.0.
### Fixes
- Don't remove file extension when recoding avatars.
- Use `BufReader` when reading .xdc files.
- No implicit member changes when we are added to the group ([#6493](https://github.com/deltachat/deltachat-core-rust/pull/6493)).
### Documentation
- jsonrpc: Update documentation for `select_account` and `get_selected_account_id` ([#6483](https://github.com/deltachat/deltachat-core-rust/pull/6483)).
- jsonrpc: Add docs for some functions.
## [1.155.1] - 2025-01-25
### Features / Changes
- Only accept SetContacts sync messages for broadcast lists.
### Fixes
- Don't create tombstones when synchronizing broadcast list members.
- Use non-empty `To:` field for "saved messages".
- Only send Chat-Group-Member-Timestamps in groups.
- Use 0 timestamps if Chat-Group-Member-Timestamps is not set.
### Refactor
- Remove BlobObject::create(), use create_and_deduplicate_from_bytes() instead ([#6467](https://github.com/deltachat/deltachat-core-rust/pull/6467)).
- Move more tests into their own files ([#6473](https://github.com/deltachat/deltachat-core-rust/pull/6473)).
## [1.155.0] - 2025-01-23
### API-Changes
- Add JSON-RPC API to get past members.
### Build system
- Update Rust.
- Increase MSRV to 1.81.0
### Features / Changes
- feat: Set BccSelf to true when receiving a sync message ([#6434](https://github.com/deltachat/deltachat-core-rust/pull/6434))
- File deduplication ([#6332](https://github.com/deltachat/deltachat-core-rust/pull/6332))
### Refactor
- Move tests to their own files.
- Extract `group_changes_msgs()` function ([#6460](https://github.com/deltachat/deltachat-core-rust/pull/6460)).
## [1.154.3] - 2025-01-20
### Build system
- Remove encoded-words from flake.nix.
- nix: Update rust-email hash in flake.nix.
### Miscellaneous Tasks
- Remove unused function delete_files_in_dir() ([#6454](https://github.com/deltachat/deltachat-core-rust/pull/6454)).
## [1.154.2] - 2025-01-20
### Features / Changes
- Add API to save messages ([#5606](https://github.com/deltachat/deltachat-core-rust/pull/5606)).
### Fixes
- fix: Don't accidentally remove Self from groups ([#6455](https://github.com/deltachat/deltachat-core-rust/pull/6455)).
- Do not create tombstones for members removed from unpromoted groups.
### Build system
- Switch to non-git version of encoded-words.
### Refactor
- Make memberlist update logic easier to follow.
## [1.154.1] - 2025-01-15
### Tests
- Expect trashing of no-op "member added" in non_member_cannot_modify_member_list.
## [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
@@ -5757,11 +5585,3 @@ https://github.com/deltachat/deltachat-core-rust/pulls?q=is%3Apr+is%3Aclosed
[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
[1.154.1]: https://github.com/deltachat/deltachat-core-rust/compare/v1.154.0..v1.154.1
[1.154.2]: https://github.com/deltachat/deltachat-core-rust/compare/v1.154.1..v1.154.2
[1.154.3]: https://github.com/deltachat/deltachat-core-rust/compare/v1.154.2..v1.154.3
[1.155.0]: https://github.com/deltachat/deltachat-core-rust/compare/v1.154.3..v1.155.0
[1.155.1]: https://github.com/deltachat/deltachat-core-rust/compare/v1.155.0..v1.155.1
[1.155.2]: https://github.com/deltachat/deltachat-core-rust/compare/v1.155.1..v1.155.2
[1.155.3]: https://github.com/deltachat/deltachat-core-rust/compare/v1.155.2..v1.155.3

View File

@@ -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
${CARGO} build --target-dir=${CMAKE_BINARY_DIR}/target --release --features jsonrpc
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/deltachat-ffi
)

1490
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
[package]
name = "deltachat"
version = "1.155.3"
version = "1.153.0"
edition = "2021"
license = "MPL-2.0"
rust-version = "1.81"
rust-version = "1.77"
repository = "https://github.com/deltachat/deltachat-core-rust"
[profile.dev]
@@ -49,23 +49,22 @@ base64 = { workspace = true }
brotli = { version = "7", default-features=false, features = ["std"] }
bytes = "1"
chrono = { workspace = true, features = ["alloc", "clock", "std"] }
data-encoding = "2.7.0"
email = { git = "https://github.com/deltachat/rust-email", branch = "master" }
encoded-words = "0.2"
encoded-words = { git = "https://github.com/async-email/encoded-words", branch = "master" }
escaper = "0.1"
fast-socks5 = "0.10"
fd-lock = "4"
futures-lite = { workspace = true }
futures = { workspace = true }
hex = "0.4.0"
hickory-resolver = "=0.25.0-alpha.4"
hickory-resolver = "=0.25.0-alpha.2"
http-body-util = "0.1.2"
humansize = "2"
hyper = "1"
hyper-util = "0.1.10"
image = { version = "0.25.5", default-features=false, features = ["gif", "jpeg", "ico", "png", "pnm", "webp", "bmp"] }
iroh-gossip = { version = "0.32", default-features = false, features = ["net"] }
iroh = { version = "0.32", default-features = false }
iroh-gossip = { version = "0.28.1", default-features = false, features = ["net"] }
iroh-net = { version = "0.28.1", default-features = false }
kamadak-exif = "0.6.1"
lettre_email = { git = "https://github.com/deltachat/lettre", branch = "master" }
libc = { workspace = true }
@@ -77,7 +76,7 @@ num-traits = { workspace = true }
once_cell = { workspace = true }
parking_lot = "0.12"
percent-encoding = "2.3"
pgp = { version = "0.15.0", default-features = false }
pgp = { version = "0.14.2", default-features = false }
pin-project = "1"
qrcodegen = "1.7.0"
quick-xml = "0.37"
@@ -86,8 +85,8 @@ rand = { workspace = true }
regex = { workspace = true }
rusqlite = { workspace = true, features = ["sqlcipher"] }
rust-hsluv = "0.1"
rustls-pki-types = "1.11.0"
rustls = { version = "0.23.22", default-features = false }
rustls-pki-types = "1.10.1"
rustls = { version = "0.23.20", default-features = false }
sanitize-filename = { workspace = true }
serde_json = { workspace = true }
serde_urlencoded = "0.7.1"
@@ -110,8 +109,7 @@ tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] }
toml = "0.8"
url = "2"
uuid = { version = "1", features = ["serde", "v4"] }
webpki-roots = "0.26.8"
blake3 = "1.5.5"
webpki-roots = "0.26.7"
[dev-dependencies]
anyhow = { workspace = true, features = ["backtrace"] } # Enable `backtrace` feature in tests.
@@ -176,7 +174,7 @@ deltachat-contact-tools = { path = "deltachat-contact-tools" }
deltachat-jsonrpc = { path = "deltachat-jsonrpc", default-features = false }
deltachat = { path = ".", default-features = false }
futures = "0.3.31"
futures-lite = "2.6.0"
futures-lite = "2.5.0"
libc = "0.2"
log = "0.4"
nu-ansi-term = "0.46"
@@ -189,7 +187,7 @@ sanitize-filename = "0.5"
serde = "1.0"
serde_json = "1"
tempfile = "3.14.0"
thiserror = "2"
thiserror = "1"
tokio = "1"
tokio-util = "0.7.13"
tracing-subscriber = "0.3"

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat_ffi"
version = "1.155.3"
version = "1.153.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 }
deltachat-jsonrpc = { workspace = true, optional = true }
libc = { workspace = true }
human-panic = { version = "2", default-features = false }
num-traits = { workspace = true }
@@ -30,4 +30,5 @@ yerpc = { workspace = true, features = ["anyhow_expose"] }
[features]
default = ["vendored"]
vendored = ["deltachat/vendored", "deltachat-jsonrpc/vendored"]
jsonrpc = ["dep:deltachat-jsonrpc"]

View File

@@ -1974,36 +1974,6 @@ void dc_delete_msgs (dc_context_t* context, const uint3
void dc_forward_msgs (dc_context_t* context, const uint32_t* msg_ids, int msg_cnt, uint32_t chat_id);
/**
* Save a copy of messages in "Saved Messages".
*
* In contrast to forwarding messages,
* information as author, date and origin are preserved.
* The action completes locally, so "Saved Messages" do not show sending errors in case one is offline.
* Still, a sync message is emitted, so that other devices will save the same message,
* as long as not deleted before.
*
* To check if a message was saved, use dc_msg_get_saved_msg_id(),
* UI may show an indicator and offer an "Unsave" instead of a "Save" button then.
*
* The other way round, from inside the "Saved Messages" chat,
* UI may show the indicator and "Unsave" button checking dc_msg_get_original_msg_id()
* and offer a button to go the original message.
*
* "Unsave" is done by deleting the saved message.
* Webxdc updates are not copied on purpose.
*
* For performance reasons, esp. when saving lots of messages,
* UI should call this function from a background thread.
*
* @memberof dc_context_t
* @param context The context object.
* @param msg_ids An array of uint32_t containing all message IDs that should be saved.
* @param msg_cnt The number of messages IDs in the msg_ids array.
*/
void dc_save_msgs (dc_context_t* context, const uint32_t* msg_ids, int msg_cnt);
/**
* Resend messages and make information available for newly added chat members.
* Resending sends out the original message, however, recipients and webxdc-status may differ.
@@ -4752,36 +4722,10 @@ void dc_msg_set_override_sender_name(dc_msg_t* msg, const char* name)
* @param file If the message object is used in dc_send_msg() later,
* this must be the full path of the image file to send.
* @param filemime The MIME type of the file. NULL if you don't know or don't care.
* @deprecated 2025-01-21 Use dc_msg_set_file_and_deduplicate instead
*/
void dc_msg_set_file (dc_msg_t* msg, const char* file, const char* filemime);
/**
* Sets the file associated with a message.
*
* If `name` is non-null, it is used as the file name
* and the actual current name of the file is ignored.
*
* If the source file is already in the blobdir, it will be renamed,
* otherwise it will be copied to the blobdir first.
*
* In order to deduplicate files that contain the same data,
* the file will be named `<hash>.<extension>`, e.g. `ce940175885d7b78f7b7e9f1396611f.jpg`.
*
* NOTE:
* - This function will rename the file. To get the new file path, call `get_file()`.
* - The file must not be modified after this function was called.
*
* @memberof dc_msg_t
* @param msg The message object. Must not be NULL.
* @param file The path of the file to attach. Must not be NULL.
* @param name The original filename of the attachment. If NULL, the current name of `file` will be used instead.
* @param filemime The MIME type of the file. NULL if you don't know or don't care.
*/
void dc_msg_set_file_and_deduplicate(dc_msg_t* msg, const char* file, const char* name, const char* filemime);
/**
* Set the dimensions associated with message object.
* Typically this is the width and the height of an image or video associated using dc_msg_set_file().
@@ -4924,35 +4868,6 @@ dc_msg_t* dc_msg_get_quoted_msg (const dc_msg_t* msg);
dc_msg_t* dc_msg_get_parent (const dc_msg_t* msg);
/**
* Get original message ID for a saved message from the "Saved Messages" chat.
*
* Can be used by UI to show a button to go the original message
* and an option to "Unsave" the message.
*
* @memberof dc_msg_t
* @param msg The message object. Usually, this refers to a a message inside "Saved Messages".
* @return The message ID of the original message.
* 0 if the given message object is not a "Saved Message"
* or if the original message does no longer exist.
*/
uint32_t dc_msg_get_original_msg_id (const dc_msg_t* msg);
/**
* Check if a message was saved and return its ID inside "Saved Messages".
*
* Deleting the returned message will un-save the message.
* The state "is saved" can be used to show some icon to indicate that a message was saved.
*
* @memberof dc_msg_t
* @param msg The message object. Usually, this refers to a a message outside "Saved Messages".
* @return The message ID inside "Saved Messages", if any.
* 0 if the given message object is not saved.
*/
uint32_t dc_msg_get_saved_msg_id (const dc_msg_t* msg);
/**
* Force the message to be sent in plain text.
*

View File

@@ -35,8 +35,6 @@ 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;
@@ -1979,26 +1977,6 @@ pub unsafe extern "C" fn dc_forward_msgs(
})
}
#[no_mangle]
pub unsafe extern "C" fn dc_save_msgs(
context: *mut dc_context_t,
msg_ids: *const u32,
msg_cnt: libc::c_int,
) {
if context.is_null() || msg_ids.is_null() || msg_cnt <= 0 {
eprintln!("ignoring careless call to dc_save_msgs()");
return;
}
let msg_ids = convert_and_prune_message_ids(msg_ids, msg_cnt);
let ctx = &*context;
block_on(async move {
chat::save_msgs(ctx, &msg_ids[..])
.await
.unwrap_or_log_default(ctx, "Failed to save message")
})
}
#[no_mangle]
pub unsafe extern "C" fn dc_resend_msgs(
context: *mut dc_context_t,
@@ -3835,33 +3813,6 @@ pub unsafe extern "C" fn dc_msg_set_file(
)
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_set_file_and_deduplicate(
msg: *mut dc_msg_t,
file: *const libc::c_char,
name: *const libc::c_char,
filemime: *const libc::c_char,
) {
if msg.is_null() || file.is_null() {
eprintln!("ignoring careless call to dc_msg_set_file_and_deduplicate()");
return;
}
let ffi_msg = &mut *msg;
let ctx = &*ffi_msg.context;
ffi_msg
.message
.set_file_and_deduplicate(
ctx,
as_path(file),
to_opt_string_lossy(name).as_deref(),
to_opt_string_lossy(filemime).as_deref(),
)
.context("Failed to set file")
.log_err(&*ffi_msg.context)
.ok();
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_set_dimension(
msg: *mut dc_msg_t,
@@ -4027,48 +3978,6 @@ pub unsafe extern "C" fn dc_msg_get_parent(msg: *const dc_msg_t) -> *mut dc_msg_
}
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_get_original_msg_id(msg: *const dc_msg_t) -> u32 {
if msg.is_null() {
eprintln!("ignoring careless call to dc_msg_get_original_msg_id()");
return 0;
}
let ffi_msg: &MessageWrapper = &*msg;
let context = &*ffi_msg.context;
block_on(async move {
ffi_msg
.message
.get_original_msg_id(context)
.await
.context("failed to get original message")
.log_err(context)
.unwrap_or_default()
.map(|id| id.to_u32())
.unwrap_or(0)
})
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_get_saved_msg_id(msg: *const dc_msg_t) -> u32 {
if msg.is_null() {
eprintln!("ignoring careless call to dc_msg_get_saved_msg_id()");
return 0;
}
let ffi_msg: &MessageWrapper = &*msg;
let context = &*ffi_msg.context;
block_on(async move {
ffi_msg
.message
.get_saved_msg_id(context)
.await
.context("failed to get original message")
.log_err(context)
.unwrap_or_default()
.map(|id| id.to_u32())
.unwrap_or(0)
})
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_force_plaintext(msg: *mut dc_msg_t) {
if msg.is_null() {
@@ -5021,97 +4930,105 @@ pub unsafe extern "C" fn dc_accounts_get_event_emitter(
Box::into_raw(Box::new(emitter))
}
pub struct dc_jsonrpc_instance_t {
receiver: OutReceiver,
handle: RpcSession<CommandApi>,
}
#[cfg(feature = "jsonrpc")]
mod jsonrpc {
use deltachat_jsonrpc::api::CommandApi;
use deltachat_jsonrpc::yerpc::{OutReceiver, RpcClient, RpcSession};
#[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();
use super::*;
pub struct dc_jsonrpc_instance_t {
receiver: OutReceiver,
handle: RpcSession<CommandApi>,
}
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 instance = dc_jsonrpc_instance_t { receiver, handle };
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;
}
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()
}
#[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 (request_handle, receiver) = RpcClient::new();
let handle = RpcSession::new(request_handle, cmd_api);
let instance = dc_jsonrpc_instance_t { receiver, handle };
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;
}
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()
}
}
None => ptr::null_mut(),
}
None => ptr::null_mut(),
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-jsonrpc"
version = "1.155.3"
version = "1.153.0"
description = "DeltaChat JSON-RPC API"
edition = "2021"
default-run = "deltachat-jsonrpc-server"

View File

@@ -212,12 +212,14 @@ impl CommandApi {
self.accounts.read().await.get_all()
}
/// Select account in account manager, this saves the last used account to accounts.toml
/// Select account id for internally selected state.
/// TODO: Likely this is deprecated as all methods take an account id now.
async fn select_account(&self, id: u32) -> Result<()> {
self.accounts.write().await.select_account(id).await
}
/// Get the selected account from the account manager (on startup it is read from accounts.toml)
/// Get the selected account id of the internal state..
/// TODO: Likely this is deprecated as all methods take an account id now.
async fn get_selected_account_id(&self) -> Option<u32> {
self.accounts.read().await.get_selected_account_id()
}
@@ -834,13 +836,6 @@ impl CommandApi {
Ok(contacts.iter().map(|id| id.to_u32()).collect::<Vec<u32>>())
}
/// Returns contact IDs of the past chat members.
async fn get_past_chat_contacts(&self, account_id: u32, chat_id: u32) -> Result<Vec<u32>> {
let ctx = self.get_context(account_id).await?;
let contacts = chat::get_past_chat_contacts(&ctx, ChatId::new(chat_id)).await?;
Ok(contacts.iter().map(|id| id.to_u32()).collect::<Vec<u32>>())
}
/// Create a new group chat.
///
/// After creation,
@@ -998,12 +993,6 @@ impl CommandApi {
marknoticed_chat(&ctx, ChatId::new(chat_id)).await
}
/// Returns the message that is immediately followed by the last seen
/// message.
/// From the point of view of the user this is effectively
/// "first unread", but in reality in the database a seen message
/// _can_ be followed by a fresh (unseen) message
/// if that message has not been individually marked as seen.
async fn get_first_unread_message_of_chat(
&self,
account_id: u32,
@@ -1092,9 +1081,6 @@ impl CommandApi {
markseen_msgs(&ctx, msg_ids.into_iter().map(MsgId::new).collect()).await
}
/// Returns all messages of a particular chat.
/// If `add_daymarker` is `true`, it will return them as
/// `DC_MSG_ID_DAYMARKER`, e.g. [1234, 1237, 9, 1239].
async fn get_message_ids(
&self,
account_id: u32,

View File

@@ -1,7 +1,7 @@
use std::time::{Duration, SystemTime};
use anyhow::{bail, Context as _, Result};
use deltachat::chat::{self, get_chat_contacts, get_past_chat_contacts, ChatVisibility};
use deltachat::chat::{self, get_chat_contacts, ChatVisibility};
use deltachat::chat::{Chat, ChatId};
use deltachat::constants::Chattype;
use deltachat::contact::{Contact, ContactId};
@@ -39,10 +39,6 @@ pub struct FullChat {
is_self_talk: bool,
contacts: Vec<ContactObject>,
contact_ids: Vec<u32>,
/// Contact IDs of the past chat members.
past_contact_ids: Vec<u32>,
color: String,
fresh_message_counter: usize,
// is_group - please check over chat.type in frontend instead
@@ -63,7 +59,6 @@ impl FullChat {
let chat = Chat::load_from_db(context, rust_chat_id).await?;
let contact_ids = get_chat_contacts(context, rust_chat_id).await?;
let past_contact_ids = get_past_chat_contacts(context, rust_chat_id).await?;
let mut contacts = Vec::with_capacity(contact_ids.len());
@@ -116,7 +111,6 @@ impl FullChat {
is_self_talk: chat.is_self_talk(),
contacts,
contact_ids: contact_ids.iter().map(|id| id.to_u32()).collect(),
past_contact_ids: past_contact_ids.iter().map(|id| id.to_u32()).collect(),
color,
fresh_message_counter,
is_contact_request: chat.is_contact_request(),

View File

@@ -101,7 +101,6 @@ pub enum EventType {
/// Incoming reaction, should be notified.
#[serde(rename_all = "camelCase")]
IncomingReaction {
chat_id: u32,
contact_id: u32,
msg_id: u32,
reaction: String,
@@ -336,12 +335,10 @@ impl From<CoreEventType> for EventType {
contact_id: contact_id.to_u32(),
},
CoreEventType::IncomingReaction {
chat_id,
contact_id,
msg_id,
reaction,
} => IncomingReaction {
chat_id: chat_id.to_u32(),
contact_id: contact_id.to_u32(),
msg_id: msg_id.to_u32(),
reaction: reaction.as_str().to_string(),

View File

@@ -58,5 +58,5 @@
},
"type": "module",
"types": "dist/deltachat.d.ts",
"version": "1.155.3"
"version": "1.153.0"
}

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-repl"
version = "1.155.3"
version = "1.153.0"
license = "MPL-2.0"
edition = "2021"
repository = "https://github.com/deltachat/deltachat-core-rust"
@@ -8,12 +8,12 @@ repository = "https://github.com/deltachat/deltachat-core-rust"
[dependencies]
anyhow = { workspace = true }
deltachat = { workspace = true, features = ["internals"]}
dirs = "6"
dirs = "5"
log = { workspace = true }
nu-ansi-term = { workspace = true }
qr2term = "0.3.3"
rusqlite = { workspace = true }
rustyline = "15"
rustyline = "14"
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] }
tracing-subscriber = { workspace = true, features = ["env-filter"] }

View File

@@ -939,7 +939,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
} else {
Viewtype::File
});
msg.set_file_and_deduplicate(&context, Path::new(arg1), None, None)?;
msg.set_file(arg1, None);
msg.set_text(arg2.to_string());
chat::send_msg(&context, sel_chat.as_ref().unwrap().get_id(), &mut msg).await?;
}

View File

@@ -22,7 +22,7 @@ use log::{error, info, warn};
use nu_ansi_term::Color;
use rustyline::completion::{Completer, FilenameCompleter, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::{CmdKind as HighlightCmdKind, Highlighter, MatchingBracketHighlighter};
use rustyline::highlight::{Highlighter, MatchingBracketHighlighter};
use rustyline::hint::{Hinter, HistoryHinter};
use rustyline::validate::Validator;
use rustyline::{
@@ -298,8 +298,8 @@ impl Highlighter for DcHelper {
self.highlighter.highlight(line, pos)
}
fn highlight_char(&self, line: &str, pos: usize, kind: HighlightCmdKind) -> bool {
self.highlighter.highlight_char(line, pos, kind)
fn highlight_char(&self, line: &str, pos: usize, forced: bool) -> bool {
self.highlighter.highlight_char(line, pos, forced)
}
}

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "1.155.3"
version = "1.153.0"
description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -13,6 +13,7 @@ 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",
@@ -23,7 +24,6 @@ classifiers = [
"Topic :: Communications :: Email"
]
readme = "README.md"
requires-python = ">=3.8"
[tool.setuptools.package-data]
deltachat_rpc_client = [

View File

@@ -238,11 +238,6 @@ class Chat:
contacts = self._rpc.get_chat_contacts(self.account.id, self.id)
return [Contact(self.account, contact_id) for contact_id in contacts]
def get_past_contacts(self) -> list[Contact]:
"""Get past contacts for this chat."""
past_contacts = self._rpc.get_past_chat_contacts(self.account.id, self.id)
return [Contact(self.account, contact_id) for contact_id in past_contacts]
def set_image(self, path: str) -> None:
"""Set profile image of this chat.

View File

@@ -131,7 +131,10 @@ class Rpc:
def reader_loop(self) -> None:
try:
while line := self.process.stdout.readline():
while True:
line = self.process.stdout.readline()
if not line: # EOF
break
response = json.loads(line)
if "id" in response:
response_id = response["id"]
@@ -147,7 +150,10 @@ class Rpc:
def writer_loop(self) -> None:
"""Writer loop ensuring only a single thread writes requests."""
try:
while request := self.request_queue.get():
while True:
request = self.request_queue.get()
if not request:
break
data = (json.dumps(request) + "\n").encode()
self.process.stdin.write(data)
self.process.stdin.flush()

View File

@@ -231,9 +231,7 @@ def test_chat(acfactory) -> None:
group.get_fresh_message_count()
group.mark_noticed()
assert group.get_contacts()
assert group.get_past_contacts() == []
group.remove_contact(alice_contact_bob)
assert len(group.get_past_contacts()) == 1
group.remove_contact(alice_chat_bob)
group.get_locations()

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-rpc-server"
version = "1.155.3"
version = "1.153.0"
description = "DeltaChat JSON-RPC server"
edition = "2021"
readme = "README.md"

View File

@@ -15,5 +15,5 @@
},
"type": "module",
"types": "index.d.ts",
"version": "1.155.3"
"version": "1.153.0"
}

View File

@@ -12,8 +12,15 @@ ignore = [
# Unmaintained encoding
"RUSTSEC-2021-0153",
# Unmaintained proc-macro-error
# <https://rustsec.org/advisories/RUSTSEC-2024-0370>
"RUSTSEC-2024-0370",
# Unmaintained instant
"RUSTSEC-2024-0384",
# idna 0.5.0
"RUSTSEC-2024-0421",
]
[bans]
@@ -26,15 +33,15 @@ skip = [
{ name = "base64", version = "<0.21" },
{ name = "base64", version = "0.21.7" },
{ name = "bitflags", version = "1.3.2" },
{ name = "core-foundation", version = "0.9.4" },
{ name = "event-listener", version = "2.5.3" },
{ name = "generator", version = "0.7.5" },
{ name = "event-listener", version = "4.0.3" },
{ name = "fastrand", version = "1.9.0" },
{ name = "fiat-crypto", version = "0.1.20" },
{ name = "futures-lite", version = "1.13.0" },
{ name = "getrandom", version = "<0.2" },
{ name = "http", version = "0.2.12" },
{ name = "loom", version = "0.5.6" },
{ name = "netlink-packet-route", version = "0.17.1" },
{ name = "idna", version = "0.5.0" },
{ name = "nix", version = "0.26.4" },
{ name = "nix", version = "0.27.1" },
{ name = "quick-error", version = "<2.0" },
{ name = "rand_chacha", version = "<0.3" },
{ name = "rand_core", version = "<0.6" },
@@ -42,33 +49,24 @@ skip = [
{ name = "redox_syscall", version = "0.3.5" },
{ name = "regex-automata", version = "0.1.10" },
{ name = "regex-syntax", version = "0.6.29" },
{ name = "rtnetlink", version = "0.13.1" },
{ name = "security-framework", version = "2.11.1" },
{ 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 = "tokio-tungstenite", version = "0.21.0" },
{ name = "tungstenite", version = "0.21.0" },
{ name = "unicode-width", version = "0.1.11" },
{ name = "wasi", version = "<0.11" },
{ name = "windows" },
{ name = "windows_aarch64_gnullvm" },
{ name = "windows_aarch64_msvc" },
{ name = "windows-core" },
{ name = "windows_i686_gnu" },
{ name = "windows_i686_gnullvm" },
{ name = "windows_i686_msvc" },
{ name = "windows-implement" },
{ name = "windows-interface" },
{ name = "windows-result" },
{ name = "windows-strings" },
{ name = "windows-sys" },
{ name = "windows-targets" },
{ name = "windows_x86_64_gnu" },
{ name = "windows_x86_64_gnullvm" },
{ name = "windows_x86_64_msvc" },
{ name = "windows_aarch64_gnullvm", version = "<0.52" },
{ name = "windows_aarch64_msvc", version = "<0.52" },
{ name = "windows-core", version = "<0.54.0" },
{ name = "windows_i686_gnu", version = "<0.52" },
{ name = "windows_i686_msvc", version = "<0.52" },
{ name = "windows-sys", version = "<0.59" },
{ name = "windows-targets", version = "<0.52" },
{ name = "windows", version = "<0.54.0" },
{ name = "windows_x86_64_gnullvm", version = "<0.52" },
{ name = "windows_x86_64_gnu", version = "<0.52" },
{ name = "windows_x86_64_msvc", version = "<0.52" },
{ name = "winreg", version = "0.50.0" },
]
@@ -99,5 +97,6 @@ license-files = [
[sources.allow-org]
# Organisations which we allow git sources from.
github = [
"async-email",
"deltachat",
]

74
flake.lock generated
View File

@@ -47,16 +47,17 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1737527504,
"narHash": "sha256-Z8S5gLPdIYeKwBXDaSxlJ72ZmiilYhu3418h3RSQZA0=",
"lastModified": 1711088506,
"narHash": "sha256-USdlY7Tx2oJWqFBpp10+03+h7eVhpkQ4s9t1ERjeIJE=",
"owner": "nix-community",
"repo": "fenix",
"rev": "aa13f23e3e91b95377a693ac655bbc6545ebec0d",
"rev": "85f4139f3c092cf4afd9f9906d7ed218ef262c97",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"rev": "85f4139f3c092cf4afd9f9906d7ed218ef262c97",
"type": "github"
}
},
@@ -114,6 +115,25 @@
"type": "github"
}
},
"new-fenix": {
"inputs": {
"nixpkgs": "nixpkgs_4",
"rust-analyzer-src": "rust-analyzer-src_2"
},
"locked": {
"lastModified": 1734417396,
"narHash": "sha256-32x1Z+Pz3Jv0cK9EG56cFTKXy/mZ/c+Ikxw+aVfKHp4=",
"owner": "nix-community",
"repo": "fenix",
"rev": "a18d41b26e998e95a598858fdb86ba22fb5da47d",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"nix-filter": {
"locked": {
"lastModified": 1730207686,
@@ -147,11 +167,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1737469691,
"narHash": "sha256-nmKOgAU48S41dTPIXAq0AHZSehWUn6ZPrUKijHAMmIk=",
"lastModified": 1731139594,
"narHash": "sha256-IigrKK3vYRpUu+HEjPL/phrfh7Ox881er1UEsZvw9Q4=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "9e4d5190a9482a1fb9d18adf0bdb83c6e506eaab",
"rev": "76612b17c0ce71689921ca12d9ffdc9c23ce40b2",
"type": "github"
},
"original": {
@@ -174,6 +194,22 @@
}
},
"nixpkgs_4": {
"locked": {
"lastModified": 1734119587,
"narHash": "sha256-AKU6qqskl0yf2+JdRdD0cfxX4b9x3KKV5RqA6wijmPM=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "3566ab7246670a43abd2ffa913cc62dad9cdf7d5",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_5": {
"locked": {
"lastModified": 1731139594,
"narHash": "sha256-IigrKK3vYRpUu+HEjPL/phrfh7Ox881er1UEsZvw9Q4=",
@@ -195,18 +231,36 @@
"fenix": "fenix",
"flake-utils": "flake-utils_2",
"naersk": "naersk",
"new-fenix": "new-fenix",
"nix-filter": "nix-filter",
"nixpkgs": "nixpkgs_4"
"nixpkgs": "nixpkgs_5"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1737453499,
"narHash": "sha256-fa5AJI9mjFU2oVXqdCq2oA2pripAXbHzkUkewJRQpxA=",
"lastModified": 1731342671,
"narHash": "sha256-36eYDHoPzjavnpmEpc2MXdzMk557S0YooGms07mDuKk=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "0b68402d781955d526b80e5d479e9e47addb4075",
"rev": "fc98e0657abf3ce07eed513e38274c89bbb2f8ad",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
},
"rust-analyzer-src_2": {
"flake": false,
"locked": {
"lastModified": 1734386068,
"narHash": "sha256-Py025JiD9lcPmldB7X1AEjq3WBTS60jZUJRtTDonmaE=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "0a706f7d2ac093985eae317781200689cfd48b78",
"type": "github"
},
"original": {

View File

@@ -1,14 +1,19 @@
{
description = "Delta Chat core";
inputs = {
fenix.url = "github:nix-community/fenix";
# Old Rust to build releases.
fenix.url = "github:nix-community/fenix?rev=85f4139f3c092cf4afd9f9906d7ed218ef262c97";
# New Rust for development shell.
new-fenix.url = "github:nix-community/fenix";
flake-utils.url = "github:numtide/flake-utils";
naersk.url = "github:nix-community/naersk";
nix-filter.url = "github:numtide/nix-filter";
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
android.url = "github:tadfisher/android-nixpkgs";
};
outputs = { self, nixpkgs, flake-utils, nix-filter, naersk, fenix, android }:
outputs = { self, nixpkgs, flake-utils, nix-filter, naersk, fenix, new-fenix, android }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
@@ -88,7 +93,8 @@
cargoLock = {
lockFile = ./Cargo.lock;
outputHashes = {
"email-0.0.20" = "sha256-cfR3D5jFQpw32bGsgapK2Uwuxmht+rRK/n1ZUmCb2WA=";
"email-0.0.20" = "sha256-rV4Uzqt2Qdrfi5Ti1r+Si1c2iW1kKyWLwOgLkQ5JGGw=";
"encoded-words-0.2.0" = "sha256-KK9st0hLFh4dsrnLd6D8lC6pRFFs8W+WpZSGMGJcosk=";
"lettre-0.9.2" = "sha256-+hU1cFacyyeC9UGVBpS14BWlJjHy90i/3ynMkKAzclk=";
};
};
@@ -538,13 +544,13 @@
let
pkgs = import nixpkgs {
system = system;
overlays = [ fenix.overlays.default ];
overlays = [ new-fenix.overlays.default ];
};
in
pkgs.mkShell {
buildInputs = with pkgs; [
(fenix.packages.${system}.complete.withComponents [
(new-fenix.packages.${system}.complete.withComponents [
"cargo"
"clippy"
"rust-src"

View File

@@ -9,7 +9,7 @@ const buildArgs = [
'build',
'--release',
'--features',
'vendored',
'vendored,jsonrpc',
'-p',
'deltachat_ffi'
]

View File

@@ -8,7 +8,7 @@ import { EventId2EventName, C } from '../dist/constants.js'
import { join } from 'path'
import { statSync } from 'fs'
import { Context } from '../dist/context.js'
import { fileURLToPath } from 'url';
import {fileURLToPath} from 'url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
@@ -444,7 +444,7 @@ describe('Offline Tests with unconfigured account', function () {
context.setChatProfileImage(chatId, imagePath)
const blobPath = context.getChat(chatId).getProfileImage()
expect(blobPath.startsWith(blobs)).to.be.true
expect(blobPath.includes('image')).to.be.false
expect(blobPath.includes('image')).to.be.true
expect(blobPath.endsWith('.jpeg')).to.be.true
context.setChatProfileImage(chatId, null)

View File

@@ -55,5 +55,5 @@
"test:mocha": "mocha node/test/test.mjs --growl --reporter=spec --bail --exit"
},
"types": "node/dist/index.d.ts",
"version": "1.155.3"
"version": "1.153.0"
}

View File

@@ -52,7 +52,10 @@ python3-venv` should give you a usable python installation.
First, build the core library::
cargo build --release -p deltachat_ffi
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.
Create the virtual environment and activate it::

View File

@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat"
version = "1.155.3"
version = "1.153.0"
description = "Python bindings for the Delta Chat Core library using CFFI against the Rust-implemented libdeltachat"
readme = "README.rst"
requires-python = ">=3.8"
requires-python = ">=3.7"
authors = [
{ name = "holger krekel, Floris Bruynooghe, Bjoern Petersen and contributors" },
]

View File

@@ -108,9 +108,7 @@ class Message:
@props.with_doc
def filename(self):
"""file path if there was an attachment, otherwise empty string.
If you want to get the file extension or a user-visible string,
use `basename` instead."""
"""filename if there was an attachment, otherwise empty string."""
return from_dc_charpointer(lib.dc_msg_get_file(self._dc_msg))
def set_file(self, path, mime_type=None):
@@ -122,8 +120,7 @@ class Message:
@props.with_doc
def basename(self) -> str:
"""The user-visible name of the attachment (incl. extension)
if it exists, otherwise empty string."""
"""basename of the attachment if it exists, otherwise empty string."""
# FIXME, it does not return basename
return from_dc_charpointer(lib.dc_msg_get_filename(self._dc_msg))

View File

@@ -181,16 +181,15 @@ def test_send_file_twice_unicode_filename_mangling(tmp_path, acfactory, lp):
msg = send_and_receive_message()
assert msg.text == "withfile"
assert open(msg.filename).read() == "some data"
msg.basename.index(basename)
assert msg.basename.endswith(ext)
msg.filename.index(basename)
assert msg.filename.endswith(ext)
msg2 = send_and_receive_message()
assert msg2.text == "withfile"
assert open(msg2.filename).read() == "some data"
msg2.basename.index(basename)
assert msg2.basename.endswith(ext)
assert msg.filename == msg2.filename # The file is deduplicated
assert msg.basename == msg2.basename
msg2.filename.index(basename)
assert msg2.filename.endswith(ext)
assert msg.filename != msg2.filename
def test_send_file_html_attachment(tmp_path, acfactory, lp):
@@ -215,8 +214,8 @@ def test_send_file_html_attachment(tmp_path, acfactory, lp):
msg = ac2.get_message_by_id(ev.data2)
assert open(msg.filename).read() == content
msg.basename.index(basename)
assert msg.basename.endswith(ext)
msg.filename.index(basename)
assert msg.filename.endswith(ext)
def test_html_message(acfactory, lp):
@@ -1983,7 +1982,7 @@ def test_fetch_deleted_msg(acfactory, lp):
if ev.name == "DC_EVENT_MSGS_CHANGED":
pytest.fail("A deleted message was shown to the user")
if ev.name == "DC_EVENT_INFO" and 'IDLE entering wait-on-remote state in folder "INBOX".' in ev.data2:
if ev.name == "DC_EVENT_INFO" and "INBOX: Idle entering wait-on-remote state" in ev.data2:
break # DC is done with reading messages

View File

@@ -1 +1 @@
2025-02-05
2025-01-05

View File

@@ -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.84.0
RUST_VERSION=1.83.0
ARCH="$(uname -m)"
test -f "/lib/libc.musl-$ARCH.so.1" && LIBC=musl || LIBC=gnu

View File

@@ -11,7 +11,7 @@ set -euo pipefail
export DCC_RS_TARGET=debug
export DCC_RS_DEV="$PWD"
cargo build -p deltachat_ffi
cargo build -p deltachat_ffi --features jsonrpc
tox -c python -e py --devenv venv
venv/bin/pip install --upgrade pip

View File

@@ -12,7 +12,7 @@ export DCC_RS_DEV=`pwd`
cd python
cargo build -p deltachat_ffi
cargo build -p deltachat_ffi --features jsonrpc
# remove and inhibit writing PYC files
rm -rf tests/__pycache__

View File

@@ -8,7 +8,7 @@ set -e -x
# compile core lib
cargo build --release -p deltachat_ffi
cargo build --release -p deltachat_ffi --features jsonrpc
# 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 py38,py39,py310,py311,py312,py313,pypy38,pypy39,pypy310 --skip-missing-interpreters true
tox --workdir "$TOXWORKDIR" -e py37,py38,py39,py310,py311,py312,py313,pypy37,pypy38,pypy39,pypy310 --skip-missing-interpreters true
auditwheel repair "$TOXWORKDIR"/wheelhouse/deltachat* -w "$TOXWORKDIR/wheelhouse"

View File

@@ -2,12 +2,13 @@
use core::cmp::max;
use std::ffi::OsStr;
use std::fmt;
use std::io::{Cursor, Seek};
use std::iter::FusedIterator;
use std::mem;
use std::path::{Path, PathBuf};
use anyhow::{ensure, format_err, Context as _, Result};
use anyhow::{format_err, Context as _, Result};
use base64::Engine as _;
use futures::StreamExt;
use image::codecs::jpeg::JpegEncoder;
@@ -15,7 +16,7 @@ use image::ImageReader;
use image::{DynamicImage, GenericImage, GenericImageView, ImageFormat, Pixel, Rgba};
use num_traits::FromPrimitive;
use tokio::io::AsyncWriteExt;
use tokio::{fs, io, task};
use tokio::{fs, io};
use tokio_stream::wrappers::ReadDirStream;
use crate::config::Config;
@@ -33,10 +34,6 @@ use crate::log::LogExt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlobObject<'a> {
blobdir: &'a Path,
/// The name of the file on the disc.
/// Note that this is NOT the user-visible filename,
/// which is only stored in Param::Filename on the message.
name: String,
}
@@ -47,7 +44,37 @@ enum ImageOutputFormat {
}
impl<'a> BlobObject<'a> {
/// Creates a new file, returning a tuple of the name and the handle.
/// Creates a new blob object with a unique name.
///
/// Creates a new file in the blob directory. The name will be
/// derived from the platform-agnostic basename of the suggested
/// name, followed by a random number and followed by a possible
/// extension. The `data` will be written into the file without
/// race-conditions.
pub async fn create(
context: &'a Context,
suggested_name: &str,
data: &[u8],
) -> Result<BlobObject<'a>> {
let blobdir = context.get_blobdir();
let (stem, ext) = BlobObject::sanitise_name(suggested_name);
let (name, mut file) = BlobObject::create_new_file(context, blobdir, &stem, &ext).await?;
file.write_all(data).await.context("file write failure")?;
// workaround a bug in async-std
// (the executor does not handle blocking operation in Drop correctly,
// see <https://github.com/async-rs/async-std/issues/900>)
let _ = file.flush().await;
let blob = BlobObject {
blobdir,
name: format!("$BLOBDIR/{name}"),
};
context.emit_event(EventType::NewBlobFile(blob.as_name().to_string()));
Ok(blob)
}
// Creates a new file, returning a tuple of the name and the handle.
async fn create_new_file(
context: &Context,
dir: &Path,
@@ -61,8 +88,6 @@ impl<'a> BlobObject<'a> {
attempt += 1;
let path = dir.join(&name);
match fs::OpenOptions::new()
// Using `create_new(true)` in order to avoid race conditions
// when creating multiple files with the same name.
.create_new(true)
.write(true)
.open(&path)
@@ -84,8 +109,8 @@ impl<'a> BlobObject<'a> {
/// Creates a new blob object with unique name by copying an existing file.
///
/// This creates a new blob
/// and copies an existing file into it. This is done in a
/// This creates a new blob as described in [BlobObject::create]
/// but also copies an existing file into it. This is done in a
/// in way which avoids race-conditions when multiple files are
/// concurrently created.
pub async fn create_and_copy(context: &'a Context, src: &Path) -> Result<BlobObject<'a>> {
@@ -103,8 +128,8 @@ impl<'a> BlobObject<'a> {
return Err(err).context("failed to copy file");
}
// Ensure that all buffered bytes are written
dst_file.flush().await?;
// workaround, see create() for details
let _ = dst_file.flush().await;
let blob = BlobObject {
blobdir: context.get_blobdir(),
@@ -114,101 +139,6 @@ impl<'a> BlobObject<'a> {
Ok(blob)
}
/// Creates a blob object by copying or renaming an existing file.
/// If the source file is already in the blobdir, it will be renamed,
/// otherwise it will be copied to the blobdir first.
///
/// In order to deduplicate files that contain the same data,
/// the file will be named `<hash>.<extension>`, e.g. `ce940175885d7b78f7b7e9f1396611f.jpg`.
/// The `original_name` param is only used to get the extension.
///
/// This is done in a in way which avoids race-conditions when multiple files are
/// concurrently created.
pub fn create_and_deduplicate(
context: &'a Context,
src: &Path,
original_name: &Path,
) -> Result<BlobObject<'a>> {
// `create_and_deduplicate{_from_bytes}()` do blocking I/O, but can still be called
// from an async context thanks to `block_in_place()`.
// Tokio's "async" I/O functions are also just thin wrappers around the blocking I/O syscalls,
// so we are doing essentially the same here.
task::block_in_place(|| {
let temp_path;
let src_in_blobdir: &Path;
let blobdir = context.get_blobdir();
if src.starts_with(blobdir) {
src_in_blobdir = src;
} else {
info!(
context,
"Source file not in blobdir. Copying instead of moving in order to prevent moving a file that was still needed."
);
temp_path = blobdir.join(format!("tmp-{}", rand::random::<u64>()));
if std::fs::copy(src, &temp_path).is_err() {
// Maybe the blobdir didn't exist
std::fs::create_dir_all(blobdir).log_err(context).ok();
std::fs::copy(src, &temp_path).context("Copying new blobfile failed")?;
};
src_in_blobdir = &temp_path;
}
let hash = file_hash(src_in_blobdir)?.to_hex();
let hash = hash.as_str();
let hash = hash.get(0..31).unwrap_or(hash);
let new_file =
if let Some(extension) = original_name.extension().filter(|e| e.len() <= 32) {
format!(
"$BLOBDIR/{hash}.{}",
extension.to_string_lossy().to_lowercase()
)
} else {
format!("$BLOBDIR/{hash}")
};
let blob = BlobObject {
blobdir,
name: new_file,
};
let new_path = blob.to_abs_path();
// This will also replace an already-existing file.
// Renaming is atomic, so this will avoid race conditions.
std::fs::rename(src_in_blobdir, &new_path)?;
context.emit_event(EventType::NewBlobFile(blob.as_name().to_string()));
Ok(blob)
})
}
/// Creates a new blob object with the file contents in `data`.
/// In order to deduplicate files that contain the same data,
/// the file will be named `<hash>.<extension>`, e.g. `ce940175885d7b78f7b7e9f1396611f.jpg`.
/// The `original_name` param is only used to get the extension.
///
/// The `data` will be written into the file without race-conditions.
///
/// This function does blocking I/O, but it can still be called from an async context
/// because `block_in_place()` is used to leave the async runtime if necessary.
pub fn create_and_deduplicate_from_bytes(
context: &'a Context,
data: &[u8],
original_name: &str,
) -> Result<BlobObject<'a>> {
task::block_in_place(|| {
let blobdir = context.get_blobdir();
let temp_path = blobdir.join(format!("tmp-{}", rand::random::<u64>()));
if std::fs::write(&temp_path, data).is_err() {
// Maybe the blobdir didn't exist
std::fs::create_dir_all(blobdir).log_err(context).ok();
std::fs::write(&temp_path, data).context("writing new blobfile failed")?;
};
BlobObject::create_and_deduplicate(context, &temp_path, Path::new(original_name))
})
}
/// Creates a blob from a file, possibly copying it to the blobdir.
///
/// If the source file is not a path to into the blob directory
@@ -280,9 +210,6 @@ impl<'a> BlobObject<'a> {
/// this string in the database or [Params]. Eventually even
/// those conversions should be handled by the type system.
///
/// Note that this is NOT the user-visible filename,
/// which is only stored in Param::Filename on the message.
///
/// [Params]: crate::param::Params
pub fn as_name(&self) -> &str {
&self.name
@@ -406,25 +333,31 @@ impl<'a> BlobObject<'a> {
/// Returns path to the stored Base64-decoded blob.
///
/// If `data` represents an image of known format, this adds the corresponding extension.
///
/// Even though this function is not async, it's OK to call it from an async context.
pub(crate) fn store_from_base64(context: &Context, data: &str) -> Result<String> {
/// If `data` represents an image of known format, this adds the corresponding extension to
/// `suggested_file_stem`.
pub(crate) async fn store_from_base64(
context: &Context,
data: &str,
suggested_file_stem: &str,
) -> Result<String> {
let buf = base64::engine::general_purpose::STANDARD.decode(data)?;
let name = if let Ok(format) = image::guess_format(&buf) {
let ext = if let Ok(format) = image::guess_format(&buf) {
if let Some(ext) = format.extensions_str().first() {
format!("file.{ext}")
format!(".{ext}")
} else {
String::new()
}
} else {
String::new()
};
let blob = BlobObject::create_and_deduplicate_from_bytes(context, &buf, &name)?;
let blob =
BlobObject::create(context, &format!("{suggested_file_stem}{ext}"), &buf).await?;
Ok(blob.as_name().to_string())
}
pub async fn recode_to_avatar_size(&mut self, context: &Context) -> Result<()> {
let blob_abs = self.to_abs_path();
let img_wh =
match MediaQuality::from_i32(context.get_config_int(Config::MediaQuality).await?)
.unwrap_or_default()
@@ -437,15 +370,16 @@ impl<'a> BlobObject<'a> {
let strict_limits = true;
// max_bytes is 20_000 bytes: Outlook servers don't allow headers larger than 32k.
// 32 / 4 * 3 = 24k if you account for base64 encoding. To be safe, we reduced this to 20k.
self.recode_to_size(
if let Some(new_name) = self.recode_to_size(
context,
None, // The name of an avatar doesn't matter
blob_abs,
maybe_sticker,
img_wh,
20_000,
strict_limits,
)?;
)? {
self.name = new_name;
}
Ok(())
}
@@ -459,9 +393,9 @@ impl<'a> BlobObject<'a> {
pub async fn recode_to_image_size(
&mut self,
context: &Context,
name: Option<String>,
maybe_sticker: &mut bool,
) -> Result<String> {
) -> Result<()> {
let blob_abs = self.to_abs_path();
let (img_wh, max_bytes) =
match MediaQuality::from_i32(context.get_config_int(Config::MediaQuality).await?)
.unwrap_or_default()
@@ -473,43 +407,35 @@ impl<'a> BlobObject<'a> {
MediaQuality::Worse => (constants::WORSE_IMAGE_SIZE, constants::WORSE_IMAGE_BYTES),
};
let strict_limits = false;
let new_name = self.recode_to_size(
if let Some(new_name) = self.recode_to_size(
context,
name,
blob_abs,
maybe_sticker,
img_wh,
max_bytes,
strict_limits,
)?;
Ok(new_name)
)? {
self.name = new_name;
}
Ok(())
}
/// If `!strict_limits`, then if `max_bytes` is exceeded, reduce the image to `img_wh` and just
/// proceed with the result.
///
/// This modifies the blob object in-place.
///
/// Additionally, if you pass the user-visible filename as `name`
/// then the updated user-visible filename will be returned;
/// this may be necessary because the format may be changed to JPG,
/// i.e. "image.png" -> "image.jpg".
fn recode_to_size(
&mut self,
context: &Context,
name: Option<String>,
mut blob_abs: PathBuf,
maybe_sticker: &mut bool,
mut img_wh: u32,
max_bytes: usize,
strict_limits: bool,
) -> Result<String> {
) -> Result<Option<String>> {
// Add white background only to avatars to spare the CPU.
let mut add_white_bg = img_wh <= constants::BALANCED_AVATAR_SIZE;
let mut no_exif = false;
let no_exif_ref = &mut no_exif;
let mut name = name.unwrap_or_else(|| self.name.clone());
let original_name = name.clone();
let res: Result<String> = tokio::task::block_in_place(move || {
let res = tokio::task::block_in_place(move || {
let mut file = std::fs::File::open(self.to_abs_path())?;
let (nr_bytes, exif) = image_metadata(&file)?;
*no_exif_ref = exif.is_none();
@@ -523,7 +449,7 @@ impl<'a> BlobObject<'a> {
file.rewind()?;
ImageReader::with_format(
std::io::BufReader::new(&file),
ImageFormat::from_path(self.to_abs_path())?,
ImageFormat::from_path(&blob_abs)?,
)
}
};
@@ -531,6 +457,7 @@ impl<'a> BlobObject<'a> {
let mut img = imgreader.decode().context("image decode failure")?;
let orientation = exif.as_ref().map(|exif| exif_orientation(exif, context));
let mut encoded = Vec::new();
let mut changed_name = None;
if *maybe_sticker {
let x_max = img.width().saturating_sub(1);
@@ -542,7 +469,7 @@ impl<'a> BlobObject<'a> {
|| img.get_pixel(x_max, y_max).0[3] == 0);
}
if *maybe_sticker && exif.is_none() {
return Ok(name);
return Ok(None);
}
img = match orientation {
@@ -639,10 +566,10 @@ impl<'a> BlobObject<'a> {
if !matches!(fmt, ImageFormat::Jpeg)
&& matches!(ofmt, ImageOutputFormat::Jpeg { .. })
{
name = Path::new(&name)
.with_extension("jpg")
.to_string_lossy()
.into_owned();
blob_abs = blob_abs.with_extension("jpg");
let file_name = blob_abs.file_name().context("No image file name (???)")?;
let file_name = file_name.to_str().context("Filename is no UTF-8 (???)")?;
changed_name = Some(format!("$BLOBDIR/{file_name}"));
}
if encoded.is_empty() {
@@ -652,12 +579,11 @@ impl<'a> BlobObject<'a> {
encode_img(&img, ofmt, &mut encoded)?;
}
self.name = BlobObject::create_and_deduplicate_from_bytes(context, &encoded, &name)
.context("failed to write recoded blob to file")?
.name;
std::fs::write(&blob_abs, &encoded)
.context("failed to write recoded blob to file")?;
}
Ok(name)
Ok(changed_name)
});
match res {
Ok(_) => res,
@@ -667,7 +593,7 @@ impl<'a> BlobObject<'a> {
context,
"Cannot recode image, using original data: {err:#}.",
);
Ok(original_name)
Ok(None)
} else {
Err(err)
}
@@ -676,23 +602,8 @@ impl<'a> BlobObject<'a> {
}
}
fn file_hash(src: &Path) -> Result<blake3::Hash> {
ensure!(
!src.starts_with("$BLOBDIR/"),
"Use `get_abs_path()` to get the absolute path of the blobfile"
);
let mut hasher = blake3::Hasher::new();
let mut src_file = std::fs::File::open(src)
.with_context(|| format!("Failed to open file {}", src.display()))?;
hasher
.update_reader(&mut src_file)
.context("update_reader")?;
let hash = hasher.finalize();
Ok(hash)
}
/// Returns image file size and Exif.
fn image_metadata(file: &std::fs::File) -> Result<(u64, Option<exif::Exif>)> {
pub fn image_metadata(file: &std::fs::File) -> Result<(u64, Option<exif::Exif>)> {
let len = file.metadata()?.len();
let mut bufreader = std::io::BufReader::new(file);
let exif = exif::Reader::new().read_from_container(&mut bufreader).ok();
@@ -713,6 +624,12 @@ fn exif_orientation(exif: &exif::Exif, context: &Context) -> i32 {
0
}
impl fmt::Display for BlobObject<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "$BLOBDIR/{}", self.name)
}
}
/// All files in the blobdir.
///
/// This exists so we can have a [`BlobDirIter`] which needs something to own the data of
@@ -845,117 +762,103 @@ fn add_white_bg(img: &mut DynamicImage) {
#[cfg(test)]
mod tests {
use std::time::Duration;
use fs::File;
use super::*;
use crate::message::{Message, Viewtype};
use crate::sql;
use crate::test_utils::{self, TestContext};
use crate::tools::SystemTime;
fn check_image_size(path: impl AsRef<Path>, width: u32, height: u32) -> image::DynamicImage {
tokio::task::block_in_place(move || {
let img = ImageReader::open(path)
.expect("failed to open image")
.with_guessed_format()
.expect("failed to guess format")
.decode()
.expect("failed to decode image");
let img = image::open(path).expect("failed to open image");
assert_eq!(img.width(), width, "invalid width");
assert_eq!(img.height(), height, "invalid height");
img
})
}
const FILE_BYTES: &[u8] = b"hello";
const FILE_DEDUPLICATED: &str = "ea8f163db38682925e4491c5e58d4bb.txt";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create() {
let t = TestContext::new().await;
let blob =
BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.txt").unwrap();
let fname = t.get_blobdir().join(FILE_DEDUPLICATED);
let blob = BlobObject::create(&t, "foo", b"hello").await.unwrap();
let fname = t.get_blobdir().join("foo");
let data = fs::read(fname).await.unwrap();
assert_eq!(data, FILE_BYTES);
assert_eq!(blob.as_name(), format!("$BLOBDIR/{FILE_DEDUPLICATED}"));
assert_eq!(blob.to_abs_path(), t.get_blobdir().join(FILE_DEDUPLICATED));
assert_eq!(data, b"hello");
assert_eq!(blob.as_name(), "$BLOBDIR/foo");
assert_eq!(blob.to_abs_path(), t.get_blobdir().join("foo"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lowercase_ext() {
let t = TestContext::new().await;
let blob =
BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.TXT").unwrap();
assert!(
blob.as_name().ends_with(".txt"),
"Blob {blob:?} should end with .txt"
);
let blob = BlobObject::create(&t, "foo.TXT", b"hello").await.unwrap();
assert_eq!(blob.as_name(), "$BLOBDIR/foo.txt");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_as_file_name() {
let t = TestContext::new().await;
let blob =
BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.txt").unwrap();
assert_eq!(blob.as_file_name(), FILE_DEDUPLICATED);
let blob = BlobObject::create(&t, "foo.txt", b"hello").await.unwrap();
assert_eq!(blob.as_file_name(), "foo.txt");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_as_rel_path() {
let t = TestContext::new().await;
let blob =
BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.txt").unwrap();
assert_eq!(blob.as_rel_path(), Path::new(FILE_DEDUPLICATED));
let blob = BlobObject::create(&t, "foo.txt", b"hello").await.unwrap();
assert_eq!(blob.as_rel_path(), Path::new("foo.txt"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_suffix() {
let t = TestContext::new().await;
let blob =
BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.txt").unwrap();
let blob = BlobObject::create(&t, "foo.txt", b"hello").await.unwrap();
assert_eq!(blob.suffix(), Some("txt"));
let blob = BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "bar").unwrap();
let blob = BlobObject::create(&t, "bar", b"world").await.unwrap();
assert_eq!(blob.suffix(), None);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_dup() {
let t = TestContext::new().await;
BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.txt").unwrap();
let foo_path = t.get_blobdir().join(FILE_DEDUPLICATED);
BlobObject::create(&t, "foo.txt", b"hello").await.unwrap();
let foo_path = t.get_blobdir().join("foo.txt");
assert!(foo_path.exists());
BlobObject::create_and_deduplicate_from_bytes(&t, b"world", "foo.txt").unwrap();
BlobObject::create(&t, "foo.txt", b"world").await.unwrap();
let mut dir = fs::read_dir(t.get_blobdir()).await.unwrap();
while let Ok(Some(dirent)) = dir.next_entry().await {
let fname = dirent.file_name();
if fname == foo_path.file_name().unwrap() {
assert_eq!(fs::read(&foo_path).await.unwrap(), FILE_BYTES);
assert_eq!(fs::read(&foo_path).await.unwrap(), b"hello");
} else {
let name = fname.to_str().unwrap();
assert!(name.starts_with("foo"));
assert!(name.ends_with(".txt"));
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_double_ext() {
async fn test_double_ext_preserved() {
let t = TestContext::new().await;
BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.tar.gz").unwrap();
let foo_path = t.get_blobdir().join(FILE_DEDUPLICATED).with_extension("gz");
BlobObject::create(&t, "foo.tar.gz", b"hello")
.await
.unwrap();
let foo_path = t.get_blobdir().join("foo.tar.gz");
assert!(foo_path.exists());
BlobObject::create_and_deduplicate_from_bytes(&t, b"world", "foo.tar.gz").unwrap();
BlobObject::create(&t, "foo.tar.gz", b"world")
.await
.unwrap();
let mut dir = fs::read_dir(t.get_blobdir()).await.unwrap();
while let Ok(Some(dirent)) = dir.next_entry().await {
let fname = dirent.file_name();
if fname == foo_path.file_name().unwrap() {
assert_eq!(fs::read(&foo_path).await.unwrap(), FILE_BYTES);
assert_eq!(fs::read(&foo_path).await.unwrap(), b"hello");
} else {
let name = fname.to_str().unwrap();
println!("{name}");
assert_eq!(name.starts_with("foo"), false);
assert_eq!(name.ends_with(".tar.gz"), false);
assert!(name.ends_with(".gz"));
assert!(name.starts_with("foo"));
assert!(name.ends_with(".tar.gz"));
}
}
}
@@ -963,10 +866,10 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_long_names() {
let t = TestContext::new().await;
let s = format!("file.{}", "a".repeat(100));
let blob = BlobObject::create_and_deduplicate_from_bytes(&t, b"data", &s).unwrap();
let s = "1".repeat(150);
let blob = BlobObject::create(&t, &s, b"data").await.unwrap();
let blobname = blob.as_name().split('/').last().unwrap();
assert!(blobname.len() < 70);
assert!(blobname.len() < 128);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -1103,15 +1006,17 @@ mod tests {
let img_wh = 128;
let maybe_sticker = &mut false;
let strict_limits = true;
blob.recode_to_size(&t, None, maybe_sticker, img_wh, 20_000, strict_limits)
.unwrap();
blob.recode_to_size(
&t,
blob.to_abs_path(),
maybe_sticker,
img_wh,
20_000,
strict_limits,
)
.unwrap();
tokio::task::block_in_place(move || {
let img = ImageReader::open(blob.to_abs_path())
.unwrap()
.with_guessed_format()
.unwrap()
.decode()
.unwrap();
let img = image::open(blob.to_abs_path()).unwrap();
assert!(img.width() == img_wh);
assert!(img.height() == img_wh);
assert_eq!(img.get_pixel(0, 0), Rgba(color));
@@ -1121,25 +1026,19 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_selfavatar_outside_blobdir() {
async fn file_size(path_buf: &Path) -> u64 {
fs::metadata(path_buf).await.unwrap().len()
}
let t = TestContext::new().await;
let avatar_src = t.dir.path().join("avatar.jpg");
let avatar_bytes = include_bytes!("../test-data/image/avatar1000x1000.jpg");
fs::write(&avatar_src, avatar_bytes).await.unwrap();
let avatar_blob = t.get_blobdir().join("avatar.jpg");
assert!(!avatar_blob.exists());
t.set_config(Config::Selfavatar, Some(avatar_src.to_str().unwrap()))
.await
.unwrap();
let avatar_blob = t.get_config(Config::Selfavatar).await.unwrap().unwrap();
let avatar_path = Path::new(&avatar_blob);
assert!(
avatar_blob.ends_with("d98cd30ed8f2129bf3968420208849d.jpg"),
"The avatar filename should be its hash, put instead it's {avatar_blob}"
);
let scaled_avatar_size = file_size(avatar_path).await;
assert!(scaled_avatar_size < avatar_bytes.len() as u64);
assert!(avatar_blob.exists());
assert!(fs::metadata(&avatar_blob).await.unwrap().len() < avatar_bytes.len() as u64);
let avatar_cfg = t.get_config(Config::Selfavatar).await.unwrap();
assert_eq!(avatar_cfg, avatar_blob.to_str().map(|s| s.to_string()));
check_image_size(avatar_src, 1000, 1000);
check_image_size(
@@ -1148,25 +1047,27 @@ mod tests {
constants::BALANCED_AVATAR_SIZE,
);
let mut blob = BlobObject::new_from_path(&t, avatar_path).await.unwrap();
async fn file_size(path_buf: &Path) -> u64 {
let file = File::open(path_buf).await.unwrap();
file.metadata().await.unwrap().len()
}
let mut blob = BlobObject::new_from_path(&t, &avatar_blob).await.unwrap();
let maybe_sticker = &mut false;
let strict_limits = true;
blob.recode_to_size(&t, None, maybe_sticker, 1000, 3000, strict_limits)
.unwrap();
let new_file_size = file_size(&blob.to_abs_path()).await;
assert!(new_file_size <= 3000);
assert!(new_file_size > 2000);
// The new file should be smaller:
assert!(new_file_size < scaled_avatar_size);
// And the original file should not be touched:
assert_eq!(file_size(avatar_path).await, scaled_avatar_size);
blob.recode_to_size(
&t,
blob.to_abs_path(),
maybe_sticker,
1000,
3000,
strict_limits,
)
.unwrap();
assert!(file_size(&avatar_blob).await <= 3000);
assert!(file_size(&avatar_blob).await > 2000);
tokio::task::block_in_place(move || {
let img = ImageReader::open(blob.to_abs_path())
.unwrap()
.with_guessed_format()
.unwrap()
.decode()
.unwrap();
let img = image::open(avatar_blob).unwrap();
assert!(img.width() > 130);
assert_eq!(img.width(), img.height());
});
@@ -1186,9 +1087,9 @@ mod tests {
.await
.unwrap();
let avatar_cfg = t.get_config(Config::Selfavatar).await.unwrap().unwrap();
assert!(
avatar_cfg.ends_with("9e7f409ac5c92b942cc4f31cee2770a.png"),
"Avatar file name {avatar_cfg} should end with its hash"
assert_eq!(
avatar_cfg,
avatar_src.with_extension("png").to_str().unwrap()
);
check_image_size(
@@ -1204,7 +1105,7 @@ mod tests {
let avatar_src = t.dir.path().join("avatar.png");
let avatar_bytes = include_bytes!("../test-data/image/avatar64x64.png");
fs::write(&avatar_src, avatar_bytes).await.unwrap();
let avatar_blob = t.get_blobdir().join("e9b6c7a78aa2e4f415644f55a553e73.png");
let avatar_blob = t.get_blobdir().join("avatar.png");
assert!(!avatar_blob.exists());
t.set_config(Config::Selfavatar, Some(avatar_src.to_str().unwrap()))
.await
@@ -1472,7 +1373,6 @@ mod tests {
.set_config(Config::MediaQuality, Some(media_quality_config))
.await?;
let file = alice.get_blobdir().join("file").with_extension(extension);
let file_name = format!("file.{extension}");
fs::write(&file, &bytes)
.await
@@ -1488,7 +1388,7 @@ mod tests {
}
let mut msg = Message::new(viewtype);
msg.set_file_and_deduplicate(&alice, &file, Some(&file_name), None)?;
msg.set_file(file.to_str().unwrap(), None);
let chat = alice.create_chat(&bob).await;
if set_draft {
chat.id.set_draft(&alice, Some(&mut msg)).await.unwrap();
@@ -1544,7 +1444,7 @@ mod tests {
.await
.context("failed to write file")?;
let mut msg = Message::new(Viewtype::Image);
msg.set_file_and_deduplicate(&alice, &file, Some("file.gif"), None)?;
msg.set_file(file.to_str().unwrap(), None);
let chat = alice.create_chat(&bob).await;
let sent = alice.send_msg(chat.id, &mut msg).await;
let bob_msg = bob.recv_msg(&sent).await;
@@ -1571,7 +1471,7 @@ mod tests {
.await
.context("failed to write file")?;
let mut msg = Message::new(Viewtype::Sticker);
msg.set_file_and_deduplicate(alice, &file, None, None)?;
msg.set_file(file.to_str().unwrap(), None);
let chat = alice.get_self_chat().await;
let sent = alice.send_msg(chat.id, &mut msg).await;
let msg = Message::load_from_db(alice, sent.sender_msg_id).await?;
@@ -1580,85 +1480,4 @@ mod tests {
assert_eq!(msg.get_viewtype(), Viewtype::Sticker);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_and_deduplicate() -> Result<()> {
let t = TestContext::new().await;
let path = t.get_blobdir().join("anyfile.dat");
fs::write(&path, b"bla").await?;
let blob = BlobObject::create_and_deduplicate(&t, &path, &path)?;
assert_eq!(blob.name, "$BLOBDIR/ce940175885d7b78f7b7e9f1396611f.dat");
assert_eq!(path.exists(), false);
assert_eq!(fs::read(&blob.to_abs_path()).await?, b"bla");
fs::write(&path, b"bla").await?;
let blob2 = BlobObject::create_and_deduplicate(&t, &path, &path)?;
assert_eq!(blob2.name, blob.name);
let path_outside_blobdir = t.dir.path().join("anyfile.dat");
fs::write(&path_outside_blobdir, b"bla").await?;
let blob3 =
BlobObject::create_and_deduplicate(&t, &path_outside_blobdir, &path_outside_blobdir)?;
assert!(path_outside_blobdir.exists());
assert_eq!(blob3.name, blob.name);
fs::write(&path, b"blabla").await?;
let blob4 = BlobObject::create_and_deduplicate(&t, &path, &path)?;
assert_ne!(blob4.name, blob.name);
fs::remove_dir_all(t.get_blobdir()).await?;
let blob5 =
BlobObject::create_and_deduplicate(&t, &path_outside_blobdir, &path_outside_blobdir)?;
assert_eq!(blob5.name, blob.name);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_and_deduplicate_from_bytes() -> Result<()> {
let t = TestContext::new().await;
fs::remove_dir(t.get_blobdir()).await?;
let blob = BlobObject::create_and_deduplicate_from_bytes(&t, b"bla", "file")?;
assert_eq!(blob.name, "$BLOBDIR/ce940175885d7b78f7b7e9f1396611f");
assert_eq!(fs::read(&blob.to_abs_path()).await?, b"bla");
let modified1 = blob.to_abs_path().metadata()?.modified()?;
// Test that the modification time of the file is updated when a new file is created
// so that it's not deleted during housekeeping.
// We can't use SystemTime::shift() here because file creation uses the actual OS time,
// which we can't mock from our code.
tokio::time::sleep(Duration::from_millis(1100)).await;
let blob2 = BlobObject::create_and_deduplicate_from_bytes(&t, b"bla", "file")?;
assert_eq!(blob2.name, blob.name);
let modified2 = blob.to_abs_path().metadata()?.modified()?;
assert_ne!(modified1, modified2);
sql::housekeeping(&t).await?;
assert!(blob2.to_abs_path().exists());
// If we do shift the time by more than 1h, the blob file will be deleted during housekeeping:
SystemTime::shift(Duration::from_secs(65 * 60));
sql::housekeeping(&t).await?;
assert_eq!(blob2.to_abs_path().exists(), false);
let blob3 = BlobObject::create_and_deduplicate_from_bytes(&t, b"blabla", "file")?;
assert_ne!(blob3.name, blob.name);
{
// If something goes wrong and the blob file is overwritten,
// the correct content should be restored:
fs::write(blob3.to_abs_path(), b"bloblo").await?;
let blob4 = BlobObject::create_and_deduplicate_from_bytes(&t, b"blabla", "file")?;
let blob4_content = fs::read(blob4.to_abs_path()).await?;
assert_eq!(blob4_content, b"blabla");
}
Ok(())
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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 add_timestamp >= remove_timestamp)
AND c.id IN(SELECT chat_id FROM chats_contacts WHERE contact_id=?2)
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 add_timestamp >= remove_timestamp))
AND (c.type!=? OR c.id IN(SELECT chat_id FROM chats_contacts WHERE contact_id=?))
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_eq!(chats.len(), 1);
assert!(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_eq!(chats.len(), 3);
assert!(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_eq!(chats.len(), 2); // device chat cannot be written and is skipped on forwarding
assert!(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_eq!(chats.len(), 1);
assert!(chats.len() == 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

View File

@@ -143,10 +143,7 @@ pub enum Config {
/// Send BCC copy to self.
///
/// Should be enabled for multidevice setups.
/// Default is 0 for chatmail accounts, 1 otherwise.
///
/// This is automatically enabled when importing/exporting a backup,
/// setting up a second device, or receiving a sync message.
/// Default is 0 for chatmail accounts before a backup export, 1 otherwise.
BccSelf,
/// True if encryption is preferred according to Autocrypt standard.
@@ -387,11 +384,6 @@ pub enum Config {
/// Enable sending and executing (applying) sync messages. Sending requires `BccSelf` to be set
/// and `Bot` unset.
///
/// On real devices, this is usually always enabled and `BccSelf` is the only setting
/// that controls whether sync messages are sent.
///
/// In tests, this is usually disabled.
#[strum(props(default = "1"))]
SyncMsgs,
@@ -455,13 +447,6 @@ pub enum Config {
/// If it has not changed, we do not store
/// the device token again.
DeviceToken,
/// Device token encrypted with OpenPGP.
///
/// We store encrypted token next to `device_token`
/// to avoid encrypting it differently and
/// storing the same token multiple times on the server.
EncryptedDeviceToken,
}
impl Config {
@@ -693,7 +678,7 @@ impl Context {
let value = match key {
Config::Selfavatar if value.is_empty() => None,
Config::Selfavatar => {
config_value = BlobObject::store_from_base64(self, value)?;
config_value = BlobObject::store_from_base64(self, value, "avatar").await?;
Some(config_value.as_str())
}
_ => Some(value),
@@ -771,8 +756,7 @@ impl Context {
.await?;
match value {
Some(path) => {
let path = get_abs_path(self, Path::new(path));
let mut blob = BlobObject::create_and_deduplicate(self, &path, &path)?;
let mut blob = BlobObject::new_from_path(self, path.as_ref()).await?;
blob.recode_to_avatar_size(self).await?;
self.sql
.set_raw_config(key.as_ref(), Some(blob.as_name()))
@@ -1151,8 +1135,6 @@ mod tests {
Ok(())
}
const SAVED_MESSAGES_DEDUPLICATED_FILE: &str = "969142cb84015bc135767bc2370934a.png";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sync() -> Result<()> {
let alice0 = TestContext::new_alice().await;
@@ -1227,7 +1209,7 @@ mod tests {
let self_chat_avatar_path = self_chat.get_profile_image(&alice0).await?.unwrap();
assert_eq!(
self_chat_avatar_path,
alice0.get_blobdir().join(SAVED_MESSAGES_DEDUPLICATED_FILE)
alice0.get_blobdir().join("icon-saved-messages.png")
);
assert!(alice1
.get_config(Config::Selfavatar)

View File

@@ -61,7 +61,10 @@ 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
self.sql
.get_raw_config_bool("configured")
.await
.map_err(Into::into)
}
/// Configures this account with the currently set parameters.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1778,7 +1778,6 @@ mod tests {
"key_id",
"webxdc_integration",
"device_token",
"encrypted_device_token",
];
let t = TestContext::new().await;
let info = t.get_info().await.unwrap();

View File

@@ -100,9 +100,7 @@ pub async fn maybe_set_logging_xdc(
context,
msg.get_viewtype(),
chat_id,
msg.param
.get_path(Param::Filename, context)
.unwrap_or_default(),
msg.param.get_path(Param::File, context).unwrap_or_default(),
msg.get_id(),
)
.await?;
@@ -115,11 +113,11 @@ pub async fn maybe_set_logging_xdc_inner(
context: &Context,
viewtype: Viewtype,
chat_id: ChatId,
filename: Option<PathBuf>,
file: Option<PathBuf>,
msg_id: MsgId,
) -> anyhow::Result<()> {
if viewtype == Viewtype::Webxdc {
if let Some(file) = filename {
if let Some(file) = file {
if let Some(file_name) = file.file_name().and_then(|name| name.to_str()) {
if file_name.starts_with("debug_logging")
&& file_name.ends_with(".xdc")

View File

@@ -440,7 +440,7 @@ mod tests {
let file = alice.get_blobdir().join("minimal.xdc");
tokio::fs::write(&file, include_bytes!("../test-data/webxdc/minimal.xdc")).await?;
let mut instance = Message::new(Viewtype::File);
instance.set_file_and_deduplicate(&alice, &file, None, None)?;
instance.set_file(file.to_str().unwrap(), None);
let _sent1 = alice.send_msg(chat_id, &mut instance).await;
alice

View File

@@ -97,9 +97,6 @@ pub enum EventType {
/// Reactions for the message changed.
IncomingReaction {
/// ID of the chat which the message belongs to.
chat_id: ChatId,
/// ID of the contact whose reaction set is changed.
contact_id: ContactId,

View File

@@ -65,15 +65,6 @@ 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,

View File

@@ -291,7 +291,7 @@ pub fn new_html_mimepart(html: String) -> PartBuilder {
mod tests {
use super::*;
use crate::chat;
use crate::chat::{forward_msgs, save_msgs};
use crate::chat::forward_msgs;
use crate::config::Config;
use crate::contact::ContactId;
use crate::message::{MessengerMessage, Viewtype};
@@ -499,38 +499,6 @@ test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x
assert!(html.contains("this is <b>html</b>"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_html_save_msg() -> Result<()> {
// Alice receives a non-delta html-message
let alice = TestContext::new_alice().await;
let chat = alice
.create_chat_with_contact("", "sender@testrun.org")
.await;
let raw = include_bytes!("../test-data/message/text_alt_plain_html.eml");
receive_imf(&alice, raw, false).await?;
let msg = alice.get_last_msg_in(chat.get_id()).await;
// Alice saves the message
let self_chat = alice.get_self_chat().await;
save_msgs(&alice, &[msg.id]).await?;
let saved_msg = alice.get_last_msg_in(self_chat.get_id()).await;
assert_ne!(saved_msg.id, msg.id);
assert_eq!(
saved_msg.get_original_msg_id(&alice).await?.unwrap(),
msg.id
);
assert!(!saved_msg.is_forwarded()); // UI should not flag "saved messages" as "forwarded"
assert_ne!(saved_msg.get_from_id(), ContactId::SELF);
assert_eq!(saved_msg.get_from_id(), msg.get_from_id());
assert_eq!(saved_msg.is_dc_message, MessengerMessage::No);
assert!(saved_msg.get_text().contains("this is plain"));
assert!(saved_msg.has_html());
let html = saved_msg.get_id().get_html(&alice).await?.unwrap();
assert!(html.contains("this is <b>html</b>"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_html_forwarding_encrypted() {
// Alice receives a non-delta html-message

View File

@@ -1331,7 +1331,7 @@ impl Session {
/// Returns the last UID fetched successfully and the info about each downloaded message.
/// If the message is incorrect or there is a failure to write a message to the database,
/// it is skipped and the error is logged.
#[expect(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
pub(crate) async fn fetch_many_msgs(
&mut self,
context: &Context,
@@ -1452,7 +1452,9 @@ impl Session {
let is_seen = fetch_response.flags().any(|flag| flag == Flag::Seen);
let Some(rfc724_mid) = uid_message_ids.get(&request_uid) else {
let rfc724_mid = if let Some(rfc724_mid) = uid_message_ids.get(&request_uid) {
rfc724_mid
} else {
error!(
context,
"No Message-ID corresponding to UID {} passed in uid_messsage_ids.",
@@ -1589,15 +1591,17 @@ impl Session {
};
if self.can_metadata() && self.can_push() {
let old_encrypted_device_token =
context.get_config(Config::EncryptedDeviceToken).await?;
let device_token_changed = context
.get_config(Config::DeviceToken)
.await?
.map_or(true, |config_token| device_token != config_token);
// Whether we need to update encrypted device token.
let device_token_changed = old_encrypted_device_token.is_none()
|| context.get_config(Config::DeviceToken).await?.as_ref() != Some(&device_token);
let new_encrypted_device_token;
if device_token_changed {
let folder = context
.get_config(Config::ConfiguredInboxFolder)
.await?
.context("INBOX is not configured")?;
let encrypted_device_token = encrypt_device_token(&device_token)
.context("Failed to encrypt device token")?;
@@ -1606,23 +1610,22 @@ impl Session {
// <https://www.rfc-editor.org/rfc/rfc7888>.
let encrypted_device_token_len = encrypted_device_token.len();
// Store device token saved on the server
// to prevent storing duplicate tokens.
// The server cannot deduplicate on its own
// because encryption gives a different
// result each time.
context
.set_config_internal(Config::DeviceToken, Some(&device_token))
.await?;
context
.set_config_internal(
Config::EncryptedDeviceToken,
Some(&encrypted_device_token),
)
.await?;
if encrypted_device_token_len <= 4096 {
new_encrypted_device_token = Some(encrypted_device_token);
self.run_command_and_check_ok(&format_setmetadata(
&folder,
&encrypted_device_token,
))
.await
.context("SETMETADATA command failed")?;
// Store device token saved on the server
// to prevent storing duplicate tokens.
// The server cannot deduplicate on its own
// because encryption gives a different
// result each time.
context
.set_config_internal(Config::DeviceToken, Some(&device_token))
.await?;
} else {
// If Apple or Google (FCM) gives us a very large token,
// do not even try to give it to IMAP servers.
@@ -1634,29 +1637,9 @@ impl Session {
// of any length, but there is no reason for tokens
// to be that large even after OpenPGP encryption.
warn!(context, "Device token is too long for LITERAL-, ignoring.");
new_encrypted_device_token = None;
}
} else {
new_encrypted_device_token = old_encrypted_device_token;
}
// Store new encrypted device token on the server
// even if it is the same as the old one.
if let Some(encrypted_device_token) = new_encrypted_device_token {
let folder = context
.get_config(Config::ConfiguredInboxFolder)
.await?
.context("INBOX is not configured")?;
self.run_command_and_check_ok(&format_setmetadata(
&folder,
&encrypted_device_token,
))
.await
.context("SETMETADATA command failed")?;
context.push_subscribed.store(true, Ordering::Relaxed);
}
context.push_subscribed.store(true, Ordering::Relaxed);
} else if !context.push_subscriber.heartbeat_subscribed().await {
let context = context.clone();
// Subscribe for heartbeat notifications.
@@ -2747,6 +2730,7 @@ mod tests {
}
}
#[allow(clippy::too_many_arguments)]
async fn check_target_folder_combination(
folder: &str,
mvbox_move: bool,

View File

@@ -1,6 +1,6 @@
use std::time::Duration;
use anyhow::{Context as _, Result};
use anyhow::{bail, Context as _, Result};
use async_channel::Receiver;
use async_imap::extensions::idle::IdleResponse;
use futures_lite::FutureExt;
@@ -38,23 +38,18 @@ impl Session {
}
if self.new_mail {
info!(
context,
"Skipping IDLE in {folder:?} because there may be new mail."
);
return Ok(self);
}
if let Ok(()) = idle_interrupt_receiver.try_recv() {
info!(context, "Skip IDLE in {folder:?} because we got interrupt.");
info!(context, "skip idle, got interrupt");
return Ok(self);
}
let mut handle = self.inner.idle();
handle
.init()
.await
.with_context(|| format!("IMAP IDLE protocol failed to init in folder {folder:?}"))?;
if let Err(err) = handle.init().await {
bail!("IMAP IDLE protocol failed to init/complete: {}", err);
}
// At this point IDLE command was sent and we received a "+ idling" response. We will now
// read from the stream without getting any data for up to `IDLE_TIMEOUT`. If we don't
@@ -68,10 +63,7 @@ impl Session {
Interrupt,
}
info!(
context,
"IDLE entering wait-on-remote state in folder {folder:?}."
);
info!(context, "{folder}: Idle entering wait-on-remote state");
let fut = idle_wait.map(|ev| ev.map(Event::IdleResponse)).race(async {
idle_interrupt_receiver.recv().await.ok();
@@ -83,19 +75,19 @@ impl Session {
match fut.await {
Ok(Event::IdleResponse(IdleResponse::NewData(x))) => {
info!(context, "{folder:?}: Idle has NewData {x:?}");
info!(context, "{folder}: Idle has NewData {:?}", x);
}
Ok(Event::IdleResponse(IdleResponse::Timeout)) => {
info!(context, "{folder:?}: Idle-wait timeout or interruption.");
info!(context, "{folder}: Idle-wait timeout or interruption");
}
Ok(Event::IdleResponse(IdleResponse::ManualInterrupt)) => {
info!(context, "{folder:?}: Idle wait was interrupted manually.");
info!(context, "{folder}: Idle wait was interrupted manually");
}
Ok(Event::Interrupt) => {
info!(context, "{folder:?}: Idle wait was interrupted.");
info!(context, "{folder}: Idle wait was interrupted");
}
Err(err) => {
warn!(context, "{folder:?}: Idle wait errored: {err:?}.");
warn!(context, "{folder}: Idle wait errored: {err:?}");
}
}

View File

@@ -26,11 +26,12 @@ pub async fn initiate_key_transfer(context: &Context) -> Result<String> {
/* this may require a keypair to be created. this may take a second ... */
let setup_file_content = render_setup_file(context, &setup_code).await?;
/* encrypting may also take a while ... */
let setup_file_blob = BlobObject::create_and_deduplicate_from_bytes(
let setup_file_blob = BlobObject::create(
context,
setup_file_content.as_bytes(),
"autocrypt-setup-message.html",
)?;
setup_file_content.as_bytes(),
)
.await?;
let chat_id = ChatId::create_for_contact(context, ContactId::SELF).await?;
let mut msg = Message {
@@ -38,8 +39,6 @@ pub async fn initiate_key_transfer(context: &Context) -> Result<String> {
..Default::default()
};
msg.param.set(Param::File, setup_file_blob.as_name());
msg.param
.set(Param::Filename, "autocrypt-setup-message.html");
msg.subject = stock_str::ac_setup_msg_subject(context).await;
msg.param
.set(Param::MimeType, "application/autocrypt-setup");

View File

@@ -33,7 +33,8 @@ use std::task::Poll;
use anyhow::{bail, format_err, Context as _, Result};
use futures_lite::FutureExt;
use iroh::{Endpoint, RelayMode};
use iroh_net::relay::RelayMode;
use iroh_net::Endpoint;
use tokio::fs;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
@@ -64,11 +65,11 @@ const BACKUP_ALPN: &[u8] = b"/deltachat/backup";
/// task use the [`Context::stop_ongoing`] mechanism.
#[derive(Debug)]
pub struct BackupProvider {
/// iroh endpoint.
/// iroh-net endpoint.
_endpoint: Endpoint,
/// iroh address.
node_addr: iroh::NodeAddr,
/// iroh-net address.
node_addr: iroh_net::NodeAddr,
/// Authentication token that should be submitted
/// to retrieve the backup.
@@ -161,7 +162,7 @@ impl BackupProvider {
async fn handle_connection(
context: Context,
conn: iroh::endpoint::Connecting,
conn: iroh_net::endpoint::Connecting,
auth_token: String,
dbfile: Arc<TempPathGuard>,
) -> Result<()> {
@@ -291,7 +292,7 @@ impl Future for BackupProvider {
pub async fn get_backup2(
context: &Context,
node_addr: iroh::NodeAddr,
node_addr: iroh_net::NodeAddr,
auth_token: String,
) -> Result<()> {
let relay_mode = RelayMode::Disabled;
@@ -341,7 +342,7 @@ pub async fn get_backup2(
/// This is a long running operation which will return only when completed.
///
/// Using [`Qr`] as argument is a bit odd as it only accepts specific variant of it. It
/// does avoid having [`iroh::NodeAddr`] in the primary API however, without
/// does avoid having [`iroh_net::NodeAddr`] in the primary API however, without
/// having to revert to untyped bytes.
pub async fn get_backup(context: &Context, qr: Qr) -> Result<()> {
match qr {
@@ -393,8 +394,7 @@ mod tests {
let file = ctx0.get_blobdir().join("hello.txt");
fs::write(&file, "i am attachment").await.unwrap();
let mut msg = Message::new(Viewtype::File);
msg.set_file_and_deduplicate(&ctx0, &file, Some("hello.txt"), Some("text/plain"))
.unwrap();
msg.set_file(file.to_str().unwrap(), Some("text/plain"));
send_msg(&ctx0, self_chat.id, &mut msg).await.unwrap();
// Prepare to transfer backup.
@@ -428,12 +428,7 @@ mod tests {
let msg = Message::load_from_db(&ctx1, *msgid).await.unwrap();
let path = msg.get_file(&ctx1).unwrap();
assert_eq!(
// That's the hash of the file:
path.with_file_name("ac1d2d284757656a8d41dc40aae4136.txt"),
path
);
assert_eq!("hello.txt", msg.get_filename().unwrap());
assert_eq!(path.with_file_name("hello.txt"), path);
let text = fs::read_to_string(&path).await.unwrap();
assert_eq!(text, "i am attachment");

View File

@@ -1074,7 +1074,7 @@ Content-Disposition: attachment; filename="location.kml"
let file = alice.get_blobdir().join(file_name);
tokio::fs::write(&file, bytes).await?;
let mut msg = Message::new(Viewtype::Image);
msg.set_file_and_deduplicate(&alice, &file, Some("logo.png"), None)?;
msg.set_file(file.to_str().unwrap(), None);
let sent = alice.send_msg(alice_chat.id, &mut msg).await;
let alice_msg = Message::load_from_db(&alice, sent.sender_msg_id).await?;
assert_eq!(alice_msg.has_location(), false);

View File

@@ -470,7 +470,6 @@ pub struct Message {
/// `In-Reply-To` header value.
pub(crate) in_reply_to: Option<String>,
pub(crate) is_dc_message: MessengerMessage,
pub(crate) original_msg_id: MsgId,
pub(crate) mime_modified: bool,
pub(crate) chat_blocked: Blocked,
pub(crate) location_id: u32,
@@ -537,7 +536,6 @@ impl Message {
" m.download_state AS download_state,",
" m.error AS error,",
" m.msgrmsg AS msgrmsg,",
" m.starred AS original_msg_id,",
" m.mime_modified AS mime_modified,",
" m.txt AS txt,",
" m.subject AS subject,",
@@ -594,7 +592,6 @@ impl Message {
error: Some(row.get::<_, String>("error")?)
.filter(|error| !error.is_empty()),
is_dc_message: row.get("msgrmsg")?,
original_msg_id: row.get("original_msg_id")?,
mime_modified: row.get("mime_modified")?,
text,
subject: row.get("subject")?,
@@ -623,8 +620,8 @@ impl Message {
pub fn get_filemime(&self) -> Option<String> {
if let Some(m) = self.param.get(Param::MimeType) {
return Some(m.to_string());
} else if self.param.exists(Param::File) {
if let Some((_, mime)) = guess_msgtype_from_suffix(self) {
} else if let Some(file) = self.param.get(Param::File) {
if let Some((_, mime)) = guess_msgtype_from_suffix(Path::new(file)) {
return Some(mime.to_string());
}
// we have a file but no mimetype, let's use a generic one
@@ -1085,62 +1082,18 @@ impl Message {
self.param.set_optional(Param::MimeType, filemime);
}
/// Sets the file associated with a message, deduplicating files with the same name.
///
/// If `name` is Some, it is used as the file name
/// and the actual current name of the file is ignored.
///
/// If the source file is already in the blobdir, it will be renamed,
/// otherwise it will be copied to the blobdir first.
///
/// In order to deduplicate files that contain the same data,
/// the file will be named `<hash>.<extension>`, e.g. `ce940175885d7b78f7b7e9f1396611f.jpg`.
///
/// NOTE:
/// - This function will rename the file. To get the new file path, call `get_file()`.
/// - The file must not be modified after this function was called.
pub fn set_file_and_deduplicate(
&mut self,
context: &Context,
file: &Path,
name: Option<&str>,
filemime: Option<&str>,
) -> Result<()> {
let name = if let Some(name) = name {
name.to_string()
} else {
file.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown_file".to_string())
};
let blob = BlobObject::create_and_deduplicate(context, file, Path::new(&name))?;
self.param.set(Param::File, blob.as_name());
self.param.set(Param::Filename, name);
self.param.set_optional(Param::MimeType, filemime);
Ok(())
}
/// Creates a new blob and sets it as a file associated with a message.
///
/// In order to deduplicate files that contain the same data,
/// the file will be named `<hash>.<extension>`, e.g. `ce940175885d7b78f7b7e9f1396611f.jpg`.
///
/// NOTE: The file must not be modified after this function was called.
pub fn set_file_from_bytes(
pub async fn set_file_from_bytes(
&mut self,
context: &Context,
name: &str,
suggested_name: &str,
data: &[u8],
filemime: Option<&str>,
) -> Result<()> {
let blob = BlobObject::create_and_deduplicate_from_bytes(context, data, name)?;
self.param.set(Param::Filename, name);
let blob = BlobObject::create(context, suggested_name, data).await?;
self.param.set(Param::Filename, suggested_name);
self.param.set(Param::File, blob.as_name());
self.param.set_optional(Param::MimeType, filemime);
Ok(())
}
@@ -1153,13 +1106,12 @@ impl Message {
);
let vcard = contact::make_vcard(context, contacts).await?;
self.set_file_from_bytes(context, "vcard.vcf", vcard.as_bytes(), None)
.await
}
/// 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
.with_context(|| format!("Could not read {path:?}"))?;
let vcard = fs::read(path).await.context("Could not read {path}")?;
if let Some(summary) = get_vcard_summary(&vcard) {
self.param.set(Param::Summary1, summary);
} else {
@@ -1302,35 +1254,6 @@ impl Message {
Ok(None)
}
/// Returns original message ID for message from "Saved Messages".
pub async fn get_original_msg_id(&self, context: &Context) -> Result<Option<MsgId>> {
if !self.original_msg_id.is_special() {
if let Some(msg) = Message::load_from_db_optional(context, self.original_msg_id).await?
{
return if msg.chat_id.is_trash() {
Ok(None)
} else {
Ok(Some(msg.id))
};
}
}
Ok(None)
}
/// Check if the message was saved and returns the corresponding message inside "Saved Messages".
/// UI can use this to show a symbol beside the message, indicating it was saved.
/// The message can be un-saved by deleting the returned message.
pub async fn get_saved_msg_id(&self, context: &Context) -> Result<Option<MsgId>> {
let res: Option<MsgId> = context
.sql
.query_get_value(
"SELECT id FROM msgs WHERE starred=? AND chat_id!=?",
(self.id, DC_CHAT_ID_TRASH),
)
.await?;
Ok(res)
}
/// Force the message to be sent in plain text.
pub fn force_plaintext(&mut self) {
self.param.set_int(Param::ForcePlaintext, 1);
@@ -1510,14 +1433,7 @@ pub async fn get_msg_read_receipts(
.await
}
pub(crate) fn guess_msgtype_from_suffix(msg: &Message) -> Option<(Viewtype, &'static str)> {
msg.param
.get(Param::Filename)
.or_else(|| msg.param.get(Param::File))
.and_then(|file| guess_msgtype_from_path_suffix(Path::new(file)))
}
pub(crate) fn guess_msgtype_from_path_suffix(path: &Path) -> Option<(Viewtype, &'static str)> {
pub(crate) fn guess_msgtype_from_suffix(path: &Path) -> Option<(Viewtype, &str)> {
let extension: &str = &path.extension()?.to_str()?.to_lowercase();
let info = match extension {
// before using viewtype other than Viewtype::File,
@@ -1751,12 +1667,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 mut msgs = Vec::with_capacity(msg_ids.len());
for &id in &msg_ids {
if let Some(msg) = context
.sql
.query_row_optional(
let msgs = context
.sql
.query_map(
&format!(
"SELECT
m.id AS id,
m.chat_id AS chat_id,
m.state AS state,
m.download_state as download_state,
@@ -1767,39 +1683,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=? 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);
}
}
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?;
if msgs
.iter()
@@ -2245,4 +2161,729 @@ pub(crate) fn normalize_text(text: &str) -> Option<String> {
}
#[cfg(test)]
mod message_tests;
mod tests {
use num_traits::FromPrimitive;
use super::*;
use crate::chat::{
self, add_contact_to_chat, marknoticed_chat, send_text_msg, ChatItem, ProtectionStatus,
};
use crate::chatlist::Chatlist;
use crate::config::Config;
use crate::reaction::send_reaction;
use crate::receive_imf::receive_imf;
use crate::test_utils as test;
use crate::test_utils::{TestContext, TestContextManager};
#[test]
fn test_guess_msgtype_from_suffix() {
assert_eq!(
guess_msgtype_from_suffix(Path::new("foo/bar-sth.mp3")),
Some((Viewtype::Audio, "audio/mpeg"))
);
assert_eq!(
guess_msgtype_from_suffix(Path::new("foo/file.html")),
Some((Viewtype::File, "text/html"))
);
assert_eq!(
guess_msgtype_from_suffix(Path::new("foo/file.xdc")),
Some((Viewtype::Webxdc, "application/webxdc+zip"))
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_webrtc_instance() {
let (webrtc_type, url) = Message::parse_webrtc_instance("basicwebrtc:https://foo/bar");
assert_eq!(webrtc_type, VideochatType::BasicWebrtc);
assert_eq!(url, "https://foo/bar");
let (webrtc_type, url) = Message::parse_webrtc_instance("bAsIcwEbrTc:url");
assert_eq!(webrtc_type, VideochatType::BasicWebrtc);
assert_eq!(url, "url");
let (webrtc_type, url) = Message::parse_webrtc_instance("https://foo/bar?key=val#key=val");
assert_eq!(webrtc_type, VideochatType::Unknown);
assert_eq!(url, "https://foo/bar?key=val#key=val");
let (webrtc_type, url) = Message::parse_webrtc_instance("jitsi:https://j.si/foo");
assert_eq!(webrtc_type, VideochatType::Jitsi);
assert_eq!(url, "https://j.si/foo");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_webrtc_instance() {
// webrtc_instance may come from an input field of the ui, be pretty tolerant on input
let instance = Message::create_webrtc_instance("https://meet.jit.si/", "123");
assert_eq!(instance, "https://meet.jit.si/123");
let instance = Message::create_webrtc_instance("https://meet.jit.si", "456");
assert_eq!(instance, "https://meet.jit.si/456");
let instance = Message::create_webrtc_instance("meet.jit.si", "789");
assert_eq!(instance, "https://meet.jit.si/789");
let instance = Message::create_webrtc_instance("bla.foo?", "123");
assert_eq!(instance, "https://bla.foo?123");
let instance = Message::create_webrtc_instance("jitsi:bla.foo#", "456");
assert_eq!(instance, "jitsi:https://bla.foo#456");
let instance = Message::create_webrtc_instance("bla.foo#room=", "789");
assert_eq!(instance, "https://bla.foo#room=789");
let instance = Message::create_webrtc_instance("https://bla.foo#room", "123");
assert_eq!(instance, "https://bla.foo#room/123");
let instance = Message::create_webrtc_instance("bla.foo#room$ROOM", "123");
assert_eq!(instance, "https://bla.foo#room123");
let instance = Message::create_webrtc_instance("bla.foo#room=$ROOM&after=cont", "234");
assert_eq!(instance, "https://bla.foo#room=234&after=cont");
let instance = Message::create_webrtc_instance(" meet.jit .si ", "789");
assert_eq!(instance, "https://meet.jit.si/789");
let instance = Message::create_webrtc_instance(" basicwebrtc: basic . stuff\n ", "12345ab");
assert_eq!(instance, "basicwebrtc:https://basic.stuff/12345ab");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_webrtc_instance_noroom() {
// webrtc_instance may come from an input field of the ui, be pretty tolerant on input
let instance = Message::create_webrtc_instance("bla.foo$NOROOM", "123");
assert_eq!(instance, "https://bla.foo");
let instance = Message::create_webrtc_instance(" bla . foo $NOROOM ", "456");
assert_eq!(instance, "https://bla.foo");
let instance = Message::create_webrtc_instance(" $NOROOM bla . foo ", "789");
assert_eq!(instance, "https://bla.foo");
let instance = Message::create_webrtc_instance(" bla.foo / $NOROOM ? a = b ", "123");
assert_eq!(instance, "https://bla.foo/?a=b");
// $ROOM has a higher precedence
let instance = Message::create_webrtc_instance("bla.foo/?$NOROOM=$ROOM", "123");
assert_eq!(instance, "https://bla.foo/?$NOROOM=123");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_width_height() {
let t = test::TestContext::new().await;
// test that get_width() and get_height() are returning some dimensions for images;
// (as the device-chat contains a welcome-images, we check that)
t.update_device_chats().await.ok();
let device_chat_id = ChatId::get_for_contact(&t, ContactId::DEVICE)
.await
.unwrap();
let mut has_image = false;
let chatitems = chat::get_chat_msgs(&t, device_chat_id).await.unwrap();
for chatitem in chatitems {
if let ChatItem::Message { msg_id } = chatitem {
if let Ok(msg) = Message::load_from_db(&t, msg_id).await {
if msg.get_viewtype() == Viewtype::Image {
has_image = true;
// just check that width/height are inside some reasonable ranges
assert!(msg.get_width() > 100);
assert!(msg.get_height() > 100);
assert!(msg.get_width() < 4000);
assert!(msg.get_height() < 4000);
}
}
}
}
assert!(has_image);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_quote() {
let d = test::TestContext::new().await;
let ctx = &d.ctx;
ctx.set_config(Config::ConfiguredAddr, Some("self@example.com"))
.await
.unwrap();
let chat = d.create_chat_with_contact("", "dest@example.com").await;
let mut msg = Message::new_text("Quoted message".to_string());
// Send message, so it gets a Message-Id.
assert!(msg.rfc724_mid.is_empty());
let msg_id = chat::send_msg(ctx, chat.id, &mut msg).await.unwrap();
let msg = Message::load_from_db(ctx, msg_id).await.unwrap();
assert!(!msg.rfc724_mid.is_empty());
let mut msg2 = Message::new(Viewtype::Text);
msg2.set_quote(ctx, Some(&msg))
.await
.expect("can't set quote");
assert_eq!(msg2.quoted_text().unwrap(), msg.get_text());
let quoted_msg = msg2
.quoted_message(ctx)
.await
.expect("error while retrieving quoted message")
.expect("quoted message not found");
assert_eq!(quoted_msg.get_text(), msg2.quoted_text().unwrap());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_no_quote() {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
tcm.send_recv_accept(alice, bob, "Hi!").await;
let msg = tcm
.send_recv(
alice,
bob,
"On 2024-08-28, Alice wrote:\n> A quote.\nNot really.",
)
.await;
assert!(msg.quoted_text().is_none());
assert!(msg.quoted_message(bob).await.unwrap().is_none());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_unencrypted_quote_encrypted_message() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_group = alice
.create_group_with_members(ProtectionStatus::Unprotected, "Group chat", &[bob])
.await;
let sent = alice.send_text(alice_group, "Hi! I created a group").await;
let bob_received_message = bob.recv_msg(&sent).await;
let bob_group = bob_received_message.chat_id;
bob_group.accept(bob).await?;
let sent = bob.send_text(bob_group, "Encrypted message").await;
let alice_received_message = alice.recv_msg(&sent).await;
assert!(alice_received_message.get_showpadlock());
// Alice adds contact without key so chat becomes unencrypted.
let alice_flubby_contact_id =
Contact::create(alice, "Flubby", "flubby@example.org").await?;
add_contact_to_chat(alice, alice_group, alice_flubby_contact_id).await?;
// Alice quotes encrypted message in unencrypted chat.
let mut msg = Message::new_text("unencrypted".to_string());
msg.set_quote(alice, Some(&alice_received_message)).await?;
chat::send_msg(alice, alice_group, &mut msg).await?;
let bob_received_message = bob.recv_msg(&alice.pop_sent_msg().await).await;
assert_eq!(bob_received_message.quoted_text().unwrap(), "...");
assert_eq!(bob_received_message.get_showpadlock(), false);
// Alice replaces a quote of encrypted message with a quote of unencrypted one.
let mut msg1 = Message::new(Viewtype::Text);
msg1.set_quote(alice, Some(&alice_received_message)).await?;
msg1.set_quote(alice, Some(&msg)).await?;
chat::send_msg(alice, alice_group, &mut msg1).await?;
let bob_received_message = bob.recv_msg(&alice.pop_sent_msg().await).await;
assert_eq!(bob_received_message.quoted_text().unwrap(), "unencrypted");
assert_eq!(bob_received_message.get_showpadlock(), false);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_chat_id() {
// Alice receives a message that pops up as a contact request
let alice = TestContext::new_alice().await;
receive_imf(
&alice,
b"From: Bob <bob@example.com>\n\
To: alice@example.org\n\
Chat-Version: 1.0\n\
Message-ID: <123@example.com>\n\
Date: Fri, 29 Jan 2021 21:37:55 +0000\n\
\n\
hello\n",
false,
)
.await
.unwrap();
// check chat-id of this message
let msg = alice.get_last_msg().await;
assert!(!msg.get_chat_id().is_special());
assert_eq!(msg.get_text(), "hello".to_string());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_set_override_sender_name() {
// send message with overridden sender name
let alice = TestContext::new_alice().await;
let alice2 = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let chat = alice.create_chat(&bob).await;
let contact_id = *chat::get_chat_contacts(&alice, chat.id)
.await
.unwrap()
.first()
.unwrap();
let contact = Contact::get_by_id(&alice, contact_id).await.unwrap();
let mut msg = Message::new_text("bla blubb".to_string());
msg.set_override_sender_name(Some("over ride".to_string()));
assert_eq!(
msg.get_override_sender_name(),
Some("over ride".to_string())
);
assert_eq!(msg.get_sender_name(&contact), "over ride".to_string());
assert_ne!(contact.get_display_name(), "over ride".to_string());
chat::send_msg(&alice, chat.id, &mut msg).await.unwrap();
let sent_msg = alice.pop_sent_msg().await;
// bob receives that message
let chat = bob.create_chat(&alice).await;
let contact_id = *chat::get_chat_contacts(&bob, chat.id)
.await
.unwrap()
.first()
.unwrap();
let contact = Contact::get_by_id(&bob, contact_id).await.unwrap();
let msg = bob.recv_msg(&sent_msg).await;
assert_eq!(msg.chat_id, chat.id);
assert_eq!(msg.text, "bla blubb");
assert_eq!(
msg.get_override_sender_name(),
Some("over ride".to_string())
);
assert_eq!(msg.get_sender_name(&contact), "over ride".to_string());
assert_ne!(contact.get_display_name(), "over ride".to_string());
// explicitly check that the message does not create a mailing list
// (mailing lists may also use `Sender:`-header)
let chat = Chat::load_from_db(&bob, msg.chat_id).await.unwrap();
assert_ne!(chat.typ, Chattype::Mailinglist);
// Alice receives message on another device.
let msg = alice2.recv_msg(&sent_msg).await;
assert_eq!(
msg.get_override_sender_name(),
Some("over ride".to_string())
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_markseen_msgs() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let alice_chat = alice.create_chat(&bob).await;
let mut msg = Message::new_text("this is the text!".to_string());
// alice sends to bob,
assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 0);
let sent1 = alice.send_msg(alice_chat.id, &mut msg).await;
let msg1 = bob.recv_msg(&sent1).await;
let bob_chat_id = msg1.chat_id;
let sent2 = alice.send_msg(alice_chat.id, &mut msg).await;
let msg2 = bob.recv_msg(&sent2).await;
assert_eq!(msg1.chat_id, msg2.chat_id);
let chats = Chatlist::try_load(&bob, 0, None, None).await?;
assert_eq!(chats.len(), 1);
let msgs = chat::get_chat_msgs(&bob, bob_chat_id).await?;
assert_eq!(msgs.len(), 2);
assert_eq!(bob.get_fresh_msgs().await?.len(), 0);
// that has no effect in contact request
markseen_msgs(&bob, vec![msg1.id, msg2.id]).await?;
assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 1);
let bob_chat = Chat::load_from_db(&bob, bob_chat_id).await?;
assert_eq!(bob_chat.blocked, Blocked::Request);
let msgs = chat::get_chat_msgs(&bob, bob_chat_id).await?;
assert_eq!(msgs.len(), 2);
bob_chat_id.accept(&bob).await.unwrap();
// bob sends to alice,
// alice knows bob and messages appear in normal chat
let msg1 = alice
.recv_msg(&bob.send_msg(bob_chat_id, &mut msg).await)
.await;
let msg2 = alice
.recv_msg(&bob.send_msg(bob_chat_id, &mut msg).await)
.await;
let chats = Chatlist::try_load(&alice, 0, None, None).await?;
assert_eq!(chats.len(), 1);
assert_eq!(chats.get_chat_id(0)?, alice_chat.id);
assert_eq!(chats.get_chat_id(0)?, msg1.chat_id);
assert_eq!(chats.get_chat_id(0)?, msg2.chat_id);
assert_eq!(alice_chat.id.get_fresh_msg_cnt(&alice).await?, 2);
assert_eq!(alice.get_fresh_msgs().await?.len(), 2);
// no message-ids, that should have no effect
markseen_msgs(&alice, vec![]).await?;
// bad message-id, that should have no effect
markseen_msgs(&alice, vec![MsgId::new(123456)]).await?;
assert_eq!(alice_chat.id.get_fresh_msg_cnt(&alice).await?, 2);
assert_eq!(alice.get_fresh_msgs().await?.len(), 2);
// mark the most recent as seen
markseen_msgs(&alice, vec![msg2.id]).await?;
assert_eq!(alice_chat.id.get_fresh_msg_cnt(&alice).await?, 1);
assert_eq!(alice.get_fresh_msgs().await?.len(), 1);
// user scrolled up - mark both as seen
markseen_msgs(&alice, vec![msg1.id, msg2.id]).await?;
assert_eq!(alice_chat.id.get_fresh_msg_cnt(&alice).await?, 0);
assert_eq!(alice.get_fresh_msgs().await?.len(), 0);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_markseen_not_downloaded_msg() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
alice.set_config(Config::DownloadLimit, Some("1")).await?;
let bob = &tcm.bob().await;
let bob_chat_id = tcm.send_recv_accept(alice, bob, "hi").await.chat_id;
let file_bytes = include_bytes!("../test-data/image/screenshot.png");
let mut msg = Message::new(Viewtype::Image);
msg.set_file_from_bytes(bob, "a.jpg", file_bytes, None)
.await?;
let sent_msg = bob.send_msg(bob_chat_id, &mut msg).await;
let msg = alice.recv_msg(&sent_msg).await;
assert_eq!(msg.download_state, DownloadState::Available);
assert!(!msg.param.get_bool(Param::WantsMdn).unwrap_or_default());
assert_eq!(msg.state, MessageState::InFresh);
markseen_msgs(alice, vec![msg.id]).await?;
// A not downloaded message can be seen only if it's seen on another device.
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
// Marking the message as seen again is a no op.
markseen_msgs(alice, vec![msg.id]).await?;
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
msg.id
.update_download_state(alice, DownloadState::InProgress)
.await?;
markseen_msgs(alice, vec![msg.id]).await?;
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
msg.id
.update_download_state(alice, DownloadState::Failure)
.await?;
markseen_msgs(alice, vec![msg.id]).await?;
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
msg.id
.update_download_state(alice, DownloadState::Undecipherable)
.await?;
markseen_msgs(alice, vec![msg.id]).await?;
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
assert!(
!alice
.sql
.exists("SELECT COUNT(*) FROM smtp_mdns", ())
.await?
);
alice.set_config(Config::DownloadLimit, None).await?;
// Let's assume that Alice and Bob resolved the problem with encryption.
let old_msg = msg;
let msg = alice.recv_msg(&sent_msg).await;
assert_eq!(msg.chat_id, old_msg.chat_id);
assert_eq!(msg.download_state, DownloadState::Done);
assert!(msg.param.get_bool(Param::WantsMdn).unwrap_or_default());
assert!(msg.get_showpadlock());
// The message state mustn't be downgraded to `InFresh`.
assert_eq!(msg.state, MessageState::InNoticed);
markseen_msgs(alice, vec![msg.id]).await?;
let msg = Message::load_from_db(alice, msg.id).await?;
assert_eq!(msg.state, MessageState::InSeen);
assert_eq!(
alice
.sql
.count("SELECT COUNT(*) FROM smtp_mdns", ())
.await?,
1
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_msg_seen_on_imap_when_downloaded() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
alice.set_config(Config::DownloadLimit, Some("1")).await?;
let bob = &tcm.bob().await;
let bob_chat_id = tcm.send_recv_accept(alice, bob, "hi").await.chat_id;
let file_bytes = include_bytes!("../test-data/image/screenshot.png");
let mut msg = Message::new(Viewtype::Image);
msg.set_file_from_bytes(bob, "a.jpg", file_bytes, None)
.await?;
let sent_msg = bob.send_msg(bob_chat_id, &mut msg).await;
let msg = alice.recv_msg(&sent_msg).await;
assert_eq!(msg.download_state, DownloadState::Available);
assert_eq!(msg.state, MessageState::InFresh);
alice.set_config(Config::DownloadLimit, None).await?;
let seen = true;
let rcvd_msg = receive_imf(alice, sent_msg.payload().as_bytes(), seen)
.await
.unwrap()
.unwrap();
assert_eq!(rcvd_msg.chat_id, msg.chat_id);
let msg = Message::load_from_db(alice, *rcvd_msg.msg_ids.last().unwrap())
.await
.unwrap();
assert_eq!(msg.download_state, DownloadState::Done);
assert!(msg.param.get_bool(Param::WantsMdn).unwrap_or_default());
assert!(msg.get_showpadlock());
assert_eq!(msg.state, MessageState::InSeen);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_state() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let alice_chat = alice.create_chat(&bob).await;
let bob_chat = bob.create_chat(&alice).await;
// check both get_state() functions,
// the one requiring a id and the one requiring an object
async fn assert_state(t: &Context, msg_id: MsgId, state: MessageState) {
assert_eq!(msg_id.get_state(t).await.unwrap(), state);
assert_eq!(
Message::load_from_db(t, msg_id).await.unwrap().get_state(),
state
);
}
// check outgoing messages states on sender side
let mut alice_msg = Message::new_text("hi!".to_string());
assert_eq!(alice_msg.get_state(), MessageState::Undefined); // message not yet in db, assert_state() won't work
alice_chat
.id
.set_draft(&alice, Some(&mut alice_msg))
.await?;
let mut alice_msg = alice_chat.id.get_draft(&alice).await?.unwrap();
assert_state(&alice, alice_msg.id, MessageState::OutDraft).await;
let msg_id = chat::send_msg(&alice, alice_chat.id, &mut alice_msg).await?;
assert_eq!(msg_id, alice_msg.id);
assert_state(&alice, alice_msg.id, MessageState::OutPending).await;
let payload = alice.pop_sent_msg().await;
assert_state(&alice, alice_msg.id, MessageState::OutDelivered).await;
set_msg_failed(&alice, &mut alice_msg, "badly failed").await?;
assert_state(&alice, alice_msg.id, MessageState::OutFailed).await;
// check incoming message states on receiver side
let bob_msg = bob.recv_msg(&payload).await;
assert_eq!(bob_chat.id, bob_msg.chat_id);
assert_state(&bob, bob_msg.id, MessageState::InFresh).await;
marknoticed_chat(&bob, bob_msg.chat_id).await?;
assert_state(&bob, bob_msg.id, MessageState::InNoticed).await;
markseen_msgs(&bob, vec![bob_msg.id]).await?;
assert_state(&bob, bob_msg.id, MessageState::InSeen).await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_is_bot() -> Result<()> {
let alice = TestContext::new_alice().await;
// Alice receives an auto-generated non-chat message.
//
// This could be a holiday notice,
// in which case the message should be marked as bot-generated,
// but the contact should not.
receive_imf(
&alice,
b"From: Claire <claire@example.com>\n\
To: alice@example.org\n\
Message-ID: <789@example.com>\n\
Auto-Submitted: auto-generated\n\
Date: Fri, 29 Jan 2021 21:37:55 +0000\n\
\n\
hello\n",
false,
)
.await?;
let msg = alice.get_last_msg().await;
assert_eq!(msg.get_text(), "hello".to_string());
assert!(msg.is_bot());
let contact = Contact::get_by_id(&alice, msg.from_id).await?;
assert!(!contact.is_bot());
// Alice receives a message from Bob the bot.
receive_imf(
&alice,
b"From: Bob <bob@example.com>\n\
To: alice@example.org\n\
Chat-Version: 1.0\n\
Message-ID: <123@example.com>\n\
Auto-Submitted: auto-generated\n\
Date: Fri, 29 Jan 2021 21:37:55 +0000\n\
\n\
hello\n",
false,
)
.await?;
let msg = alice.get_last_msg().await;
assert_eq!(msg.get_text(), "hello".to_string());
assert!(msg.is_bot());
let contact = Contact::get_by_id(&alice, msg.from_id).await?;
assert!(contact.is_bot());
// Alice receives a message from Bob who is not the bot anymore.
receive_imf(
&alice,
b"From: Bob <bob@example.com>\n\
To: alice@example.org\n\
Chat-Version: 1.0\n\
Message-ID: <456@example.com>\n\
Date: Fri, 29 Jan 2021 21:37:55 +0000\n\
\n\
hello again\n",
false,
)
.await?;
let msg = alice.get_last_msg().await;
assert_eq!(msg.get_text(), "hello again".to_string());
assert!(!msg.is_bot());
let contact = Contact::get_by_id(&alice, msg.from_id).await?;
assert!(!contact.is_bot());
Ok(())
}
#[test]
fn test_viewtype_derive_display_works_as_expected() {
assert_eq!(format!("{}", Viewtype::Audio), "Audio");
}
#[test]
fn test_viewtype_values() {
// values may be written to disk and must not change
assert_eq!(Viewtype::Unknown, Viewtype::default());
assert_eq!(Viewtype::Unknown, Viewtype::from_i32(0).unwrap());
assert_eq!(Viewtype::Text, Viewtype::from_i32(10).unwrap());
assert_eq!(Viewtype::Image, Viewtype::from_i32(20).unwrap());
assert_eq!(Viewtype::Gif, Viewtype::from_i32(21).unwrap());
assert_eq!(Viewtype::Sticker, Viewtype::from_i32(23).unwrap());
assert_eq!(Viewtype::Audio, Viewtype::from_i32(40).unwrap());
assert_eq!(Viewtype::Voice, Viewtype::from_i32(41).unwrap());
assert_eq!(Viewtype::Video, Viewtype::from_i32(50).unwrap());
assert_eq!(Viewtype::File, Viewtype::from_i32(60).unwrap());
assert_eq!(
Viewtype::VideochatInvitation,
Viewtype::from_i32(70).unwrap()
);
assert_eq!(Viewtype::Webxdc, Viewtype::from_i32(80).unwrap());
assert_eq!(Viewtype::Vcard, Viewtype::from_i32(90).unwrap());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_quotes() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let chat = alice.create_chat(&bob).await;
let sent = alice.send_text(chat.id, "> First quote").await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, "> First quote");
assert!(received.quoted_text().is_none());
assert!(received.quoted_message(&bob).await?.is_none());
let sent = alice.send_text(chat.id, "> Second quote").await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, "> Second quote");
assert!(received.quoted_text().is_none());
assert!(received.quoted_message(&bob).await?.is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_message_summary_text() -> Result<()> {
let t = TestContext::new_alice().await;
let chat = t.get_self_chat().await;
let msg_id = send_text_msg(&t, chat.id, "foo".to_string()).await?;
let msg = Message::load_from_db(&t, msg_id).await?;
let summary = msg.get_summary(&t, None).await?;
assert_eq!(summary.text, "foo");
// message summary does not change when reactions are applied (in contrast to chatlist summary)
send_reaction(&t, msg_id, "🫵").await?;
let msg = Message::load_from_db(&t, msg_id).await?;
let summary = msg.get_summary(&t, None).await?;
assert_eq!(summary.text, "foo");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_format_flowed_round_trip() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let chat = alice.create_chat(&bob).await;
let text = " Foo bar";
let sent = alice.send_text(chat.id, text).await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, text);
let text = "Foo bar baz";
let sent = alice.send_text(chat.id, text).await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, text);
let text = "> xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx > A";
let sent = alice.send_text(chat.id, text).await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, text);
let python_program = "\
def hello():
return 'Hello, world!'";
let sent = alice.send_text(chat.id, python_program).await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, python_program);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_delete_msgs_offline() -> Result<()> {
let alice = TestContext::new_alice().await;
let chat = alice
.create_chat_with_contact("Bob", "bob@example.org")
.await;
let mut msg = Message::new_text("hi".to_string());
assert!(chat::send_msg_sync(&alice, chat.id, &mut msg)
.await
.is_err());
let stmt = "SELECT COUNT(*) FROM smtp WHERE msg_id=?";
assert!(alice.sql.exists(stmt, (msg.id,)).await?);
delete_msgs(&alice, &[msg.id]).await?;
assert!(!alice.sql.exists(stmt, (msg.id,)).await?);
Ok(())
}
}

View File

@@ -1,757 +0,0 @@
use num_traits::FromPrimitive;
use super::*;
use crate::chat::{
self, add_contact_to_chat, forward_msgs, marknoticed_chat, save_msgs, send_text_msg, ChatItem,
ProtectionStatus,
};
use crate::chatlist::Chatlist;
use crate::config::Config;
use crate::reaction::send_reaction;
use crate::receive_imf::receive_imf;
use crate::test_utils as test;
use crate::test_utils::{TestContext, TestContextManager};
#[test]
fn test_guess_msgtype_from_suffix() {
assert_eq!(
guess_msgtype_from_path_suffix(Path::new("foo/bar-sth.mp3")),
Some((Viewtype::Audio, "audio/mpeg"))
);
assert_eq!(
guess_msgtype_from_path_suffix(Path::new("foo/file.html")),
Some((Viewtype::File, "text/html"))
);
assert_eq!(
guess_msgtype_from_path_suffix(Path::new("foo/file.xdc")),
Some((Viewtype::Webxdc, "application/webxdc+zip"))
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_webrtc_instance() {
let (webrtc_type, url) = Message::parse_webrtc_instance("basicwebrtc:https://foo/bar");
assert_eq!(webrtc_type, VideochatType::BasicWebrtc);
assert_eq!(url, "https://foo/bar");
let (webrtc_type, url) = Message::parse_webrtc_instance("bAsIcwEbrTc:url");
assert_eq!(webrtc_type, VideochatType::BasicWebrtc);
assert_eq!(url, "url");
let (webrtc_type, url) = Message::parse_webrtc_instance("https://foo/bar?key=val#key=val");
assert_eq!(webrtc_type, VideochatType::Unknown);
assert_eq!(url, "https://foo/bar?key=val#key=val");
let (webrtc_type, url) = Message::parse_webrtc_instance("jitsi:https://j.si/foo");
assert_eq!(webrtc_type, VideochatType::Jitsi);
assert_eq!(url, "https://j.si/foo");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_webrtc_instance() {
// webrtc_instance may come from an input field of the ui, be pretty tolerant on input
let instance = Message::create_webrtc_instance("https://meet.jit.si/", "123");
assert_eq!(instance, "https://meet.jit.si/123");
let instance = Message::create_webrtc_instance("https://meet.jit.si", "456");
assert_eq!(instance, "https://meet.jit.si/456");
let instance = Message::create_webrtc_instance("meet.jit.si", "789");
assert_eq!(instance, "https://meet.jit.si/789");
let instance = Message::create_webrtc_instance("bla.foo?", "123");
assert_eq!(instance, "https://bla.foo?123");
let instance = Message::create_webrtc_instance("jitsi:bla.foo#", "456");
assert_eq!(instance, "jitsi:https://bla.foo#456");
let instance = Message::create_webrtc_instance("bla.foo#room=", "789");
assert_eq!(instance, "https://bla.foo#room=789");
let instance = Message::create_webrtc_instance("https://bla.foo#room", "123");
assert_eq!(instance, "https://bla.foo#room/123");
let instance = Message::create_webrtc_instance("bla.foo#room$ROOM", "123");
assert_eq!(instance, "https://bla.foo#room123");
let instance = Message::create_webrtc_instance("bla.foo#room=$ROOM&after=cont", "234");
assert_eq!(instance, "https://bla.foo#room=234&after=cont");
let instance = Message::create_webrtc_instance(" meet.jit .si ", "789");
assert_eq!(instance, "https://meet.jit.si/789");
let instance = Message::create_webrtc_instance(" basicwebrtc: basic . stuff\n ", "12345ab");
assert_eq!(instance, "basicwebrtc:https://basic.stuff/12345ab");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_webrtc_instance_noroom() {
// webrtc_instance may come from an input field of the ui, be pretty tolerant on input
let instance = Message::create_webrtc_instance("bla.foo$NOROOM", "123");
assert_eq!(instance, "https://bla.foo");
let instance = Message::create_webrtc_instance(" bla . foo $NOROOM ", "456");
assert_eq!(instance, "https://bla.foo");
let instance = Message::create_webrtc_instance(" $NOROOM bla . foo ", "789");
assert_eq!(instance, "https://bla.foo");
let instance = Message::create_webrtc_instance(" bla.foo / $NOROOM ? a = b ", "123");
assert_eq!(instance, "https://bla.foo/?a=b");
// $ROOM has a higher precedence
let instance = Message::create_webrtc_instance("bla.foo/?$NOROOM=$ROOM", "123");
assert_eq!(instance, "https://bla.foo/?$NOROOM=123");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_width_height() {
let t = test::TestContext::new().await;
// test that get_width() and get_height() are returning some dimensions for images;
// (as the device-chat contains a welcome-images, we check that)
t.update_device_chats().await.ok();
let device_chat_id = ChatId::get_for_contact(&t, ContactId::DEVICE)
.await
.unwrap();
let mut has_image = false;
let chatitems = chat::get_chat_msgs(&t, device_chat_id).await.unwrap();
for chatitem in chatitems {
if let ChatItem::Message { msg_id } = chatitem {
if let Ok(msg) = Message::load_from_db(&t, msg_id).await {
if msg.get_viewtype() == Viewtype::Image {
has_image = true;
// just check that width/height are inside some reasonable ranges
assert!(msg.get_width() > 100);
assert!(msg.get_height() > 100);
assert!(msg.get_width() < 4000);
assert!(msg.get_height() < 4000);
}
}
}
}
assert!(has_image);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_quote() {
let d = test::TestContext::new().await;
let ctx = &d.ctx;
ctx.set_config(Config::ConfiguredAddr, Some("self@example.com"))
.await
.unwrap();
let chat = d.create_chat_with_contact("", "dest@example.com").await;
let mut msg = Message::new_text("Quoted message".to_string());
// Send message, so it gets a Message-Id.
assert!(msg.rfc724_mid.is_empty());
let msg_id = chat::send_msg(ctx, chat.id, &mut msg).await.unwrap();
let msg = Message::load_from_db(ctx, msg_id).await.unwrap();
assert!(!msg.rfc724_mid.is_empty());
let mut msg2 = Message::new(Viewtype::Text);
msg2.set_quote(ctx, Some(&msg))
.await
.expect("can't set quote");
assert_eq!(msg2.quoted_text().unwrap(), msg.get_text());
let quoted_msg = msg2
.quoted_message(ctx)
.await
.expect("error while retrieving quoted message")
.expect("quoted message not found");
assert_eq!(quoted_msg.get_text(), msg2.quoted_text().unwrap());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_no_quote() {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
tcm.send_recv_accept(alice, bob, "Hi!").await;
let msg = tcm
.send_recv(
alice,
bob,
"On 2024-08-28, Alice wrote:\n> A quote.\nNot really.",
)
.await;
assert!(msg.quoted_text().is_none());
assert!(msg.quoted_message(bob).await.unwrap().is_none());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_unencrypted_quote_encrypted_message() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_group = alice
.create_group_with_members(ProtectionStatus::Unprotected, "Group chat", &[bob])
.await;
let sent = alice.send_text(alice_group, "Hi! I created a group").await;
let bob_received_message = bob.recv_msg(&sent).await;
let bob_group = bob_received_message.chat_id;
bob_group.accept(bob).await?;
let sent = bob.send_text(bob_group, "Encrypted message").await;
let alice_received_message = alice.recv_msg(&sent).await;
assert!(alice_received_message.get_showpadlock());
// Alice adds contact without key so chat becomes unencrypted.
let alice_flubby_contact_id = Contact::create(alice, "Flubby", "flubby@example.org").await?;
add_contact_to_chat(alice, alice_group, alice_flubby_contact_id).await?;
// Alice quotes encrypted message in unencrypted chat.
let mut msg = Message::new_text("unencrypted".to_string());
msg.set_quote(alice, Some(&alice_received_message)).await?;
chat::send_msg(alice, alice_group, &mut msg).await?;
let bob_received_message = bob.recv_msg(&alice.pop_sent_msg().await).await;
assert_eq!(bob_received_message.quoted_text().unwrap(), "...");
assert_eq!(bob_received_message.get_showpadlock(), false);
// Alice replaces a quote of encrypted message with a quote of unencrypted one.
let mut msg1 = Message::new(Viewtype::Text);
msg1.set_quote(alice, Some(&alice_received_message)).await?;
msg1.set_quote(alice, Some(&msg)).await?;
chat::send_msg(alice, alice_group, &mut msg1).await?;
let bob_received_message = bob.recv_msg(&alice.pop_sent_msg().await).await;
assert_eq!(bob_received_message.quoted_text().unwrap(), "unencrypted");
assert_eq!(bob_received_message.get_showpadlock(), false);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_chat_id() {
// Alice receives a message that pops up as a contact request
let alice = TestContext::new_alice().await;
receive_imf(
&alice,
b"From: Bob <bob@example.com>\n\
To: alice@example.org\n\
Chat-Version: 1.0\n\
Message-ID: <123@example.com>\n\
Date: Fri, 29 Jan 2021 21:37:55 +0000\n\
\n\
hello\n",
false,
)
.await
.unwrap();
// check chat-id of this message
let msg = alice.get_last_msg().await;
assert!(!msg.get_chat_id().is_special());
assert_eq!(msg.get_text(), "hello".to_string());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_set_override_sender_name() {
// send message with overridden sender name
let alice = TestContext::new_alice().await;
let alice2 = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let chat = alice.create_chat(&bob).await;
let contact_id = *chat::get_chat_contacts(&alice, chat.id)
.await
.unwrap()
.first()
.unwrap();
let contact = Contact::get_by_id(&alice, contact_id).await.unwrap();
let mut msg = Message::new_text("bla blubb".to_string());
msg.set_override_sender_name(Some("over ride".to_string()));
assert_eq!(
msg.get_override_sender_name(),
Some("over ride".to_string())
);
assert_eq!(msg.get_sender_name(&contact), "over ride".to_string());
assert_ne!(contact.get_display_name(), "over ride".to_string());
chat::send_msg(&alice, chat.id, &mut msg).await.unwrap();
let sent_msg = alice.pop_sent_msg().await;
// bob receives that message
let chat = bob.create_chat(&alice).await;
let contact_id = *chat::get_chat_contacts(&bob, chat.id)
.await
.unwrap()
.first()
.unwrap();
let contact = Contact::get_by_id(&bob, contact_id).await.unwrap();
let msg = bob.recv_msg(&sent_msg).await;
assert_eq!(msg.chat_id, chat.id);
assert_eq!(msg.text, "bla blubb");
assert_eq!(
msg.get_override_sender_name(),
Some("over ride".to_string())
);
assert_eq!(msg.get_sender_name(&contact), "over ride".to_string());
assert_ne!(contact.get_display_name(), "over ride".to_string());
// explicitly check that the message does not create a mailing list
// (mailing lists may also use `Sender:`-header)
let chat = Chat::load_from_db(&bob, msg.chat_id).await.unwrap();
assert_ne!(chat.typ, Chattype::Mailinglist);
// Alice receives message on another device.
let msg = alice2.recv_msg(&sent_msg).await;
assert_eq!(
msg.get_override_sender_name(),
Some("over ride".to_string())
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_original_msg_id() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
// normal sending of messages does not have an original ID
let one2one_chat = alice.create_chat(&bob).await;
let sent = alice.send_text(one2one_chat.id, "foo").await;
let orig_msg = Message::load_from_db(&alice, sent.sender_msg_id).await?;
assert!(orig_msg.get_original_msg_id(&alice).await?.is_none());
assert!(orig_msg.parent(&alice).await?.is_none());
assert!(orig_msg.quoted_message(&alice).await?.is_none());
// forwarding to "Saved Messages", the message gets the original ID attached
let self_chat = alice.get_self_chat().await;
save_msgs(&alice, &[sent.sender_msg_id]).await?;
let saved_msg = alice.get_last_msg_in(self_chat.get_id()).await;
assert_ne!(saved_msg.get_id(), orig_msg.get_id());
assert_eq!(
saved_msg.get_original_msg_id(&alice).await?.unwrap(),
orig_msg.get_id()
);
assert!(saved_msg.parent(&alice).await?.is_none());
assert!(saved_msg.quoted_message(&alice).await?.is_none());
// forwarding from "Saved Messages" back to another chat, detaches original ID
forward_msgs(&alice, &[saved_msg.get_id()], one2one_chat.get_id()).await?;
let forwarded_msg = alice.get_last_msg_in(one2one_chat.get_id()).await;
assert_ne!(forwarded_msg.get_id(), saved_msg.get_id());
assert_ne!(forwarded_msg.get_id(), orig_msg.get_id());
assert!(forwarded_msg.get_original_msg_id(&alice).await?.is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_markseen_msgs() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let alice_chat = alice.create_chat(&bob).await;
let mut msg = Message::new_text("this is the text!".to_string());
// alice sends to bob,
assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 0);
let sent1 = alice.send_msg(alice_chat.id, &mut msg).await;
let msg1 = bob.recv_msg(&sent1).await;
let bob_chat_id = msg1.chat_id;
let sent2 = alice.send_msg(alice_chat.id, &mut msg).await;
let msg2 = bob.recv_msg(&sent2).await;
assert_eq!(msg1.chat_id, msg2.chat_id);
let chats = Chatlist::try_load(&bob, 0, None, None).await?;
assert_eq!(chats.len(), 1);
let msgs = chat::get_chat_msgs(&bob, bob_chat_id).await?;
assert_eq!(msgs.len(), 2);
assert_eq!(bob.get_fresh_msgs().await?.len(), 0);
// that has no effect in contact request
markseen_msgs(&bob, vec![msg1.id, msg2.id]).await?;
assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 1);
let bob_chat = Chat::load_from_db(&bob, bob_chat_id).await?;
assert_eq!(bob_chat.blocked, Blocked::Request);
let msgs = chat::get_chat_msgs(&bob, bob_chat_id).await?;
assert_eq!(msgs.len(), 2);
bob_chat_id.accept(&bob).await.unwrap();
// bob sends to alice,
// alice knows bob and messages appear in normal chat
let msg1 = alice
.recv_msg(&bob.send_msg(bob_chat_id, &mut msg).await)
.await;
let msg2 = alice
.recv_msg(&bob.send_msg(bob_chat_id, &mut msg).await)
.await;
let chats = Chatlist::try_load(&alice, 0, None, None).await?;
assert_eq!(chats.len(), 1);
assert_eq!(chats.get_chat_id(0)?, alice_chat.id);
assert_eq!(chats.get_chat_id(0)?, msg1.chat_id);
assert_eq!(chats.get_chat_id(0)?, msg2.chat_id);
assert_eq!(alice_chat.id.get_fresh_msg_cnt(&alice).await?, 2);
assert_eq!(alice.get_fresh_msgs().await?.len(), 2);
// no message-ids, that should have no effect
markseen_msgs(&alice, vec![]).await?;
// bad message-id, that should have no effect
markseen_msgs(&alice, vec![MsgId::new(123456)]).await?;
assert_eq!(alice_chat.id.get_fresh_msg_cnt(&alice).await?, 2);
assert_eq!(alice.get_fresh_msgs().await?.len(), 2);
// mark the most recent as seen
markseen_msgs(&alice, vec![msg2.id]).await?;
assert_eq!(alice_chat.id.get_fresh_msg_cnt(&alice).await?, 1);
assert_eq!(alice.get_fresh_msgs().await?.len(), 1);
// user scrolled up - mark both as seen
markseen_msgs(&alice, vec![msg1.id, msg2.id]).await?;
assert_eq!(alice_chat.id.get_fresh_msg_cnt(&alice).await?, 0);
assert_eq!(alice.get_fresh_msgs().await?.len(), 0);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_markseen_not_downloaded_msg() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
alice.set_config(Config::DownloadLimit, Some("1")).await?;
let bob = &tcm.bob().await;
let bob_chat_id = tcm.send_recv_accept(alice, bob, "hi").await.chat_id;
let file_bytes = include_bytes!("../../test-data/image/screenshot.png");
let mut msg = Message::new(Viewtype::Image);
msg.set_file_from_bytes(bob, "a.jpg", file_bytes, None)?;
let sent_msg = bob.send_msg(bob_chat_id, &mut msg).await;
let msg = alice.recv_msg(&sent_msg).await;
assert_eq!(msg.download_state, DownloadState::Available);
assert!(!msg.param.get_bool(Param::WantsMdn).unwrap_or_default());
assert_eq!(msg.state, MessageState::InFresh);
markseen_msgs(alice, vec![msg.id]).await?;
// A not downloaded message can be seen only if it's seen on another device.
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
// Marking the message as seen again is a no op.
markseen_msgs(alice, vec![msg.id]).await?;
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
msg.id
.update_download_state(alice, DownloadState::InProgress)
.await?;
markseen_msgs(alice, vec![msg.id]).await?;
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
msg.id
.update_download_state(alice, DownloadState::Failure)
.await?;
markseen_msgs(alice, vec![msg.id]).await?;
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
msg.id
.update_download_state(alice, DownloadState::Undecipherable)
.await?;
markseen_msgs(alice, vec![msg.id]).await?;
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
assert!(
!alice
.sql
.exists("SELECT COUNT(*) FROM smtp_mdns", ())
.await?
);
alice.set_config(Config::DownloadLimit, None).await?;
// Let's assume that Alice and Bob resolved the problem with encryption.
let old_msg = msg;
let msg = alice.recv_msg(&sent_msg).await;
assert_eq!(msg.chat_id, old_msg.chat_id);
assert_eq!(msg.download_state, DownloadState::Done);
assert!(msg.param.get_bool(Param::WantsMdn).unwrap_or_default());
assert!(msg.get_showpadlock());
// The message state mustn't be downgraded to `InFresh`.
assert_eq!(msg.state, MessageState::InNoticed);
markseen_msgs(alice, vec![msg.id]).await?;
let msg = Message::load_from_db(alice, msg.id).await?;
assert_eq!(msg.state, MessageState::InSeen);
assert_eq!(
alice
.sql
.count("SELECT COUNT(*) FROM smtp_mdns", ())
.await?,
1
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_msg_seen_on_imap_when_downloaded() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
alice.set_config(Config::DownloadLimit, Some("1")).await?;
let bob = &tcm.bob().await;
let bob_chat_id = tcm.send_recv_accept(alice, bob, "hi").await.chat_id;
let file_bytes = include_bytes!("../../test-data/image/screenshot.png");
let mut msg = Message::new(Viewtype::Image);
msg.set_file_from_bytes(bob, "a.jpg", file_bytes, None)?;
let sent_msg = bob.send_msg(bob_chat_id, &mut msg).await;
let msg = alice.recv_msg(&sent_msg).await;
assert_eq!(msg.download_state, DownloadState::Available);
assert_eq!(msg.state, MessageState::InFresh);
alice.set_config(Config::DownloadLimit, None).await?;
let seen = true;
let rcvd_msg = receive_imf(alice, sent_msg.payload().as_bytes(), seen)
.await
.unwrap()
.unwrap();
assert_eq!(rcvd_msg.chat_id, msg.chat_id);
let msg = Message::load_from_db(alice, *rcvd_msg.msg_ids.last().unwrap())
.await
.unwrap();
assert_eq!(msg.download_state, DownloadState::Done);
assert!(msg.param.get_bool(Param::WantsMdn).unwrap_or_default());
assert!(msg.get_showpadlock());
assert_eq!(msg.state, MessageState::InSeen);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_state() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let alice_chat = alice.create_chat(&bob).await;
let bob_chat = bob.create_chat(&alice).await;
// check both get_state() functions,
// the one requiring a id and the one requiring an object
async fn assert_state(t: &Context, msg_id: MsgId, state: MessageState) {
assert_eq!(msg_id.get_state(t).await.unwrap(), state);
assert_eq!(
Message::load_from_db(t, msg_id).await.unwrap().get_state(),
state
);
}
// check outgoing messages states on sender side
let mut alice_msg = Message::new_text("hi!".to_string());
assert_eq!(alice_msg.get_state(), MessageState::Undefined); // message not yet in db, assert_state() won't work
alice_chat
.id
.set_draft(&alice, Some(&mut alice_msg))
.await?;
let mut alice_msg = alice_chat.id.get_draft(&alice).await?.unwrap();
assert_state(&alice, alice_msg.id, MessageState::OutDraft).await;
let msg_id = chat::send_msg(&alice, alice_chat.id, &mut alice_msg).await?;
assert_eq!(msg_id, alice_msg.id);
assert_state(&alice, alice_msg.id, MessageState::OutPending).await;
let payload = alice.pop_sent_msg().await;
assert_state(&alice, alice_msg.id, MessageState::OutDelivered).await;
set_msg_failed(&alice, &mut alice_msg, "badly failed").await?;
assert_state(&alice, alice_msg.id, MessageState::OutFailed).await;
// check incoming message states on receiver side
let bob_msg = bob.recv_msg(&payload).await;
assert_eq!(bob_chat.id, bob_msg.chat_id);
assert_state(&bob, bob_msg.id, MessageState::InFresh).await;
marknoticed_chat(&bob, bob_msg.chat_id).await?;
assert_state(&bob, bob_msg.id, MessageState::InNoticed).await;
markseen_msgs(&bob, vec![bob_msg.id]).await?;
assert_state(&bob, bob_msg.id, MessageState::InSeen).await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_is_bot() -> Result<()> {
let alice = TestContext::new_alice().await;
// Alice receives an auto-generated non-chat message.
//
// This could be a holiday notice,
// in which case the message should be marked as bot-generated,
// but the contact should not.
receive_imf(
&alice,
b"From: Claire <claire@example.com>\n\
To: alice@example.org\n\
Message-ID: <789@example.com>\n\
Auto-Submitted: auto-generated\n\
Date: Fri, 29 Jan 2021 21:37:55 +0000\n\
\n\
hello\n",
false,
)
.await?;
let msg = alice.get_last_msg().await;
assert_eq!(msg.get_text(), "hello".to_string());
assert!(msg.is_bot());
let contact = Contact::get_by_id(&alice, msg.from_id).await?;
assert!(!contact.is_bot());
// Alice receives a message from Bob the bot.
receive_imf(
&alice,
b"From: Bob <bob@example.com>\n\
To: alice@example.org\n\
Chat-Version: 1.0\n\
Message-ID: <123@example.com>\n\
Auto-Submitted: auto-generated\n\
Date: Fri, 29 Jan 2021 21:37:55 +0000\n\
\n\
hello\n",
false,
)
.await?;
let msg = alice.get_last_msg().await;
assert_eq!(msg.get_text(), "hello".to_string());
assert!(msg.is_bot());
let contact = Contact::get_by_id(&alice, msg.from_id).await?;
assert!(contact.is_bot());
// Alice receives a message from Bob who is not the bot anymore.
receive_imf(
&alice,
b"From: Bob <bob@example.com>\n\
To: alice@example.org\n\
Chat-Version: 1.0\n\
Message-ID: <456@example.com>\n\
Date: Fri, 29 Jan 2021 21:37:55 +0000\n\
\n\
hello again\n",
false,
)
.await?;
let msg = alice.get_last_msg().await;
assert_eq!(msg.get_text(), "hello again".to_string());
assert!(!msg.is_bot());
let contact = Contact::get_by_id(&alice, msg.from_id).await?;
assert!(!contact.is_bot());
Ok(())
}
#[test]
fn test_viewtype_derive_display_works_as_expected() {
assert_eq!(format!("{}", Viewtype::Audio), "Audio");
}
#[test]
fn test_viewtype_values() {
// values may be written to disk and must not change
assert_eq!(Viewtype::Unknown, Viewtype::default());
assert_eq!(Viewtype::Unknown, Viewtype::from_i32(0).unwrap());
assert_eq!(Viewtype::Text, Viewtype::from_i32(10).unwrap());
assert_eq!(Viewtype::Image, Viewtype::from_i32(20).unwrap());
assert_eq!(Viewtype::Gif, Viewtype::from_i32(21).unwrap());
assert_eq!(Viewtype::Sticker, Viewtype::from_i32(23).unwrap());
assert_eq!(Viewtype::Audio, Viewtype::from_i32(40).unwrap());
assert_eq!(Viewtype::Voice, Viewtype::from_i32(41).unwrap());
assert_eq!(Viewtype::Video, Viewtype::from_i32(50).unwrap());
assert_eq!(Viewtype::File, Viewtype::from_i32(60).unwrap());
assert_eq!(
Viewtype::VideochatInvitation,
Viewtype::from_i32(70).unwrap()
);
assert_eq!(Viewtype::Webxdc, Viewtype::from_i32(80).unwrap());
assert_eq!(Viewtype::Vcard, Viewtype::from_i32(90).unwrap());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_quotes() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let chat = alice.create_chat(&bob).await;
let sent = alice.send_text(chat.id, "> First quote").await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, "> First quote");
assert!(received.quoted_text().is_none());
assert!(received.quoted_message(&bob).await?.is_none());
let sent = alice.send_text(chat.id, "> Second quote").await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, "> Second quote");
assert!(received.quoted_text().is_none());
assert!(received.quoted_message(&bob).await?.is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_message_summary_text() -> Result<()> {
let t = TestContext::new_alice().await;
let chat = t.get_self_chat().await;
let msg_id = send_text_msg(&t, chat.id, "foo".to_string()).await?;
let msg = Message::load_from_db(&t, msg_id).await?;
let summary = msg.get_summary(&t, None).await?;
assert_eq!(summary.text, "foo");
// message summary does not change when reactions are applied (in contrast to chatlist summary)
send_reaction(&t, msg_id, "🫵").await?;
let msg = Message::load_from_db(&t, msg_id).await?;
let summary = msg.get_summary(&t, None).await?;
assert_eq!(summary.text, "foo");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_format_flowed_round_trip() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let chat = alice.create_chat(&bob).await;
let text = " Foo bar";
let sent = alice.send_text(chat.id, text).await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, text);
let text = "Foo bar baz";
let sent = alice.send_text(chat.id, text).await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, text);
let text = "> xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx > A";
let sent = alice.send_text(chat.id, text).await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, text);
let python_program = "\
def hello():
return 'Hello, world!'";
let sent = alice.send_text(chat.id, python_program).await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, python_program);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_delete_msgs_offline() -> Result<()> {
let alice = TestContext::new_alice().await;
let chat = alice
.create_chat_with_contact("Bob", "bob@example.org")
.await;
let mut msg = Message::new_text("hi".to_string());
assert!(chat::send_msg_sync(&alice, chat.id, &mut msg)
.await
.is_err());
let stmt = "SELECT COUNT(*) FROM smtp WHERE msg_id=?";
assert!(alice.sql.exists(stmt, (msg.id,)).await?);
delete_msgs(&alice, &[msg.id]).await?;
assert!(!alice.sql.exists(stmt, (msg.id,)).await?);
Ok(())
}

View File

@@ -1,7 +1,6 @@
//! # MIME message production.
use std::collections::HashSet;
use std::path::Path;
use anyhow::{bail, Context as _, Result};
use base64::Engine as _;
@@ -67,36 +66,8 @@ pub struct MimeFactory {
selfstatus: 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>,
/// Vector of pairs of recipient name and address
recipients: Vec<(String, String)>,
timestamp: i64,
loaded: Loaded,
@@ -155,10 +126,8 @@ fn new_address_with_name(name: &str, address: String) -> Address {
impl MimeFactory {
pub async fn from_msg(context: &Context, msg: Message) -> Result<MimeFactory> {
let now = time();
let chat = Chat::load_from_db(context, msg.chat_id).await?;
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
@@ -176,101 +145,47 @@ impl MimeFactory {
(name, None)
};
let mut recipients = Vec::new();
let mut to = Vec::new();
let mut past_members = Vec::new();
let mut member_timestamps = Vec::new();
let mut recipients = Vec::with_capacity(5);
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_addr.to_string());
recipients.push((from_displayname.to_string(), 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")?;
to.push(("".to_string(), list_post.to_string()));
recipients.push(list_post.to_string());
recipients.push(("".to_string(), 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, 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),
"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,),
|row| {
let authname: String = row.get(0)?;
let addr: String = row.get(1)?;
let id: ContactId = row.get(2)?;
let add_timestamp: i64 = row.get(3)?;
let remove_timestamp: i64 = row.get(4)?;
Ok((authname, addr, id, add_timestamp, remove_timestamp))
Ok((authname, addr, id))
},
|rows| {
let mut past_member_timestamps = Vec::new();
for row in rows {
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 if remove_timestamp.saturating_add(60 * 24 * 3600) > now {
// 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);
}
}
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));
}
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(())
},
)
@@ -311,19 +226,12 @@ 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,
@@ -351,10 +259,7 @@ impl MimeFactory {
from_displayname: "".to_string(),
sender_displayname: None,
selfstatus: "".to_string(),
recipients: vec![contact.get_addr().to_string()],
to: vec![("".to_string(), contact.get_addr().to_string())],
past_members: vec![],
member_timestamps: vec![],
recipients: vec![("".to_string(), contact.get_addr().to_string())],
timestamp,
loaded: Loaded::Mdn {
rfc724_mid,
@@ -378,7 +283,11 @@ 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()));
}
@@ -566,7 +475,10 @@ impl MimeFactory {
}
pub fn recipients(&self) -> Vec<String> {
self.recipients.clone()
self.recipients
.iter()
.map(|(_, addr)| addr.clone())
.collect()
}
/// Consumes a `MimeFactory` and renders it into a message which is then stored in
@@ -576,33 +488,46 @@ 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();
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() {
if undisclosed_recipients {
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
@@ -615,31 +540,6 @@ 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 let Loaded::Message { chat, .. } = &self.loaded {
if chat.typ == Chattype::Group
&& !self.member_timestamps.is_empty()
&& !chat.member_list_is_stale(context).await?
{
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
@@ -1149,6 +1049,7 @@ impl MimeFactory {
part.body(text)
}
#[allow(clippy::cognitive_complexity)]
async fn render_message(
&mut self,
context: &Context,
@@ -1611,17 +1512,12 @@ pub(crate) fn wrapped_base64_encode(buf: &[u8]) -> String {
}
async fn build_body_file(context: &Context, msg: &Message) -> Result<PartBuilder> {
let file_name = msg.get_filename().context("msg has no file")?;
let suffix = Path::new(&file_name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("dat");
let blob = msg
.param
.get_blob(Param::File, context)
.await?
.context("msg has no file")?;
let suffix = blob.suffix().unwrap_or("dat");
// Get file name to use for sending. For privacy purposes, we do
// not transfer the original filenames eg. for images; these names
@@ -1661,14 +1557,18 @@ async fn build_body_file(context: &Context, msg: &Message) -> Result<PartBuilder
),
&suffix
),
_ => file_name,
_ => msg
.param
.get(Param::Filename)
.unwrap_or_else(|| blob.as_file_name())
.to_string(),
};
/* check mimetype */
let mimetype: mime::Mime = match msg.param.get(Param::MimeType) {
Some(mtype) => mtype.parse()?,
None => {
if let Some(res) = message::guess_msgtype_from_suffix(msg) {
if let Some(res) = message::guess_msgtype_from_suffix(blob.as_rel_path()) {
res.1.parse()?
} else {
mime::APPLICATION_OCTET_STREAM
@@ -2561,9 +2461,8 @@ 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_addr).await?;
let claire_id = Contact::create(&alice, "Claire", "claire@foo.de").await?;
let alice_chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "foo").await?;
add_contact_to_chat(&alice, alice_chat_id, bob_id).await?;
@@ -2579,17 +2478,10 @@ mod tests {
.get_first_header("To")
.context("no To: header parsed")?;
let to = addrparse_header(to)?;
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");
}
}
}
let mailbox = to
.extract_single_info()
.context("to: field does not contain exactly one address")?;
assert_eq!(mailbox.addr, "bob@example.net");
Ok(())
}
@@ -2631,7 +2523,8 @@ mod tests {
// Long messages are truncated and MimeMessage::decoded_data is set for them. We need
// decoded_data to check presence of the necessary headers.
msg.set_text("a".repeat(constants::DC_DESIRED_TEXT_LEN + 1));
msg.set_file_from_bytes(&bob, "foo.bar", "content".as_bytes(), None)?;
msg.set_file_from_bytes(&bob, "foo.bar", "content".as_bytes(), None)
.await?;
let sent = bob.send_msg(chat, &mut msg).await;
assert!(msg.get_showpadlock());
assert!(sent.payload.contains("\r\nSubject: [...]\r\n"));
@@ -2648,40 +2541,4 @@ mod tests {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_dont_remove_self() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let first_group = alice
.create_group_with_members(ProtectionStatus::Unprotected, "First group", &[bob])
.await;
alice.send_text(first_group, "Hi! I created a group.").await;
remove_contact_from_chat(alice, first_group, ContactId::SELF).await?;
alice.pop_sent_msg().await;
let second_group = alice
.create_group_with_members(ProtectionStatus::Unprotected, "First group", &[bob])
.await;
let sent = alice
.send_text(second_group, "Hi! I created another group.")
.await;
println!("{}", sent.payload);
let mime_message = MimeMessage::from_bytes(alice, sent.payload.as_bytes(), None)
.await
.unwrap();
assert_eq!(
mime_message.get_header(HeaderDef::ChatGroupPastMembers),
None
);
assert_eq!(
mime_message.chat_group_member_timestamps().unwrap().len(),
1 // There is a timestamp for Bob, not for Alice
);
Ok(())
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@ use crate::context::Context;
use crate::net::proxy::ProxyConfig;
use crate::net::session::SessionStream;
use crate::net::tls::wrap_rustls;
use crate::tools::time;
use crate::tools::{create_id, time};
/// HTTP(S) GET response.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -119,8 +119,12 @@ fn http_url_cache_timestamps(url: &str, mimetype: Option<&str>) -> (i64, i64) {
/// Places the binary into HTTP cache.
async fn http_cache_put(context: &Context, url: &str, response: &Response) -> Result<()> {
let blob =
BlobObject::create_and_deduplicate_from_bytes(context, response.blob.as_slice(), "")?;
let blob = BlobObject::create(
context,
&format!("http_cache_{}", create_id()),
response.blob.as_slice(),
)
.await?;
let (expires, stale) = http_url_cache_timestamps(url, response.mimetype.as_deref());
context

View File

@@ -373,6 +373,7 @@ impl Params {
/// Note that in the [ParamsFile::FsPath] case the blob can be
/// created without copying if the path already refers to a valid
/// blob. If so a [BlobObject] will be returned.
#[allow(clippy::needless_lifetimes)]
pub async fn get_blob<'a>(
&self,
key: Param,

View File

@@ -24,12 +24,14 @@
//! and `joinRealtimeChannel().send()` just like the other peers.
use anyhow::{anyhow, bail, Context as _, Result};
use data_encoding::BASE32_NOPAD;
use email::Header;
use futures_lite::StreamExt;
use iroh::{Endpoint, NodeAddr, NodeId, PublicKey, RelayMap, RelayMode, RelayUrl, SecretKey};
use iroh_gossip::net::{Event, Gossip, GossipEvent, JoinOptions, GOSSIP_ALPN};
use iroh_gossip::proto::TopicId;
use iroh_net::key::{PublicKey, SecretKey};
use iroh_net::relay::{RelayMap, RelayUrl};
use iroh_net::{relay::RelayMode, Endpoint};
use iroh_net::{NodeAddr, NodeId};
use parking_lot::Mutex;
use std::collections::{BTreeSet, HashMap};
use std::env;
@@ -52,8 +54,8 @@ const PUBLIC_KEY_STUB: &[u8] = "static_string".as_bytes();
/// Store iroh peer channels for the context.
#[derive(Debug)]
pub struct Iroh {
/// iroh router needed for iroh peer channels.
pub(crate) router: iroh::protocol::Router,
/// [Endpoint] needed for iroh peer channels.
pub(crate) endpoint: Endpoint,
/// [Gossip] needed for iroh peer channels.
pub(crate) gossip: Gossip,
@@ -73,12 +75,15 @@ pub struct Iroh {
impl Iroh {
/// Notify the endpoint that the network has changed.
pub(crate) async fn network_change(&self) {
self.router.endpoint().network_change().await
self.endpoint.network_change().await
}
/// Closes the QUIC endpoint.
pub(crate) async fn close(self) -> Result<()> {
self.router.shutdown().await.context("Closing iroh failed")
self.endpoint
.close(0u32.into(), b"")
.await
.context("Closing iroh endpoint failed")
}
/// Join a topic and create the subscriber loop for it.
@@ -115,8 +120,8 @@ impl Iroh {
// Inform iroh of potentially new node addresses
for node_addr in &peers {
if !node_addr.is_empty() {
self.router.endpoint().add_node_addr(node_addr.clone())?;
if !node_addr.info.is_empty() {
self.endpoint.add_node_addr(node_addr.clone())?;
}
}
@@ -124,7 +129,7 @@ impl Iroh {
let (gossip_sender, gossip_receiver) = self
.gossip
.subscribe_with_opts(topic, JoinOptions::with_bootstrap(node_ids))
.join_with_opts(topic, JoinOptions::with_bootstrap(node_ids))
.split();
let ctx = ctx.clone();
@@ -143,10 +148,10 @@ impl Iroh {
pub async fn maybe_add_gossip_peers(&self, topic: TopicId, peers: Vec<NodeAddr>) -> Result<()> {
if self.iroh_channels.read().await.get(&topic).is_some() {
for peer in &peers {
self.router.endpoint().add_node_addr(peer.clone())?;
self.endpoint.add_node_addr(peer.clone())?;
}
self.gossip.subscribe_with_opts(
self.gossip.join_with_opts(
topic,
JoinOptions::with_bootstrap(peers.into_iter().map(|peer| peer.node_id)),
);
@@ -193,8 +198,8 @@ impl Iroh {
/// Get the iroh [NodeAddr] without direct IP addresses.
pub(crate) async fn get_node_addr(&self) -> Result<NodeAddr> {
let mut addr = self.router.endpoint().node_addr().await?;
addr.direct_addresses = BTreeSet::new();
let mut addr = self.endpoint.node_addr().await?;
addr.info.direct_addresses = BTreeSet::new();
Ok(addr)
}
@@ -237,7 +242,7 @@ impl Context {
/// Create iroh endpoint and gossip.
async fn init_peer_channels(&self) -> Result<Iroh> {
info!(self, "Initializing peer channels.");
let secret_key = SecretKey::generate(rand::rngs::OsRng);
let secret_key = SecretKey::generate();
let public_key = secret_key.public();
let relay_mode = if let Some(relay_url) = self
@@ -262,22 +267,24 @@ impl Context {
.await?;
// create gossip
// Allow messages up to 128 KB in size.
// We set the limit to 128 KiB to account for internal overhead,
// but only guarantee 128 KB of payload to WebXDC developers.
let my_addr = endpoint.node_addr().await?;
let gossip_config = iroh_gossip::proto::topic::Config {
// Allow messages up to 128 KB in size.
// We set the limit to 128 KiB to account for internal overhead,
// but only guarantee 128 KB of payload to WebXDC developers.
max_message_size: 128 * 1024,
..Default::default()
};
let gossip = Gossip::from_endpoint(endpoint.clone(), gossip_config, &my_addr.info);
let gossip = Gossip::builder()
.max_message_size(128 * 1024)
.spawn(endpoint.clone())
.await?;
// spawn endpoint loop that forwards incoming connections to the gossiper
let context = self.clone();
let router = iroh::protocol::Router::builder(endpoint)
.accept(GOSSIP_ALPN, gossip.clone())
.spawn()
.await?;
// Shuts down on deltachat shutdown
tokio::spawn(endpoint_loop(context, endpoint.clone(), gossip.clone()));
Ok(Iroh {
router,
endpoint,
gossip,
sequence_numbers: Mutex::new(HashMap::new()),
iroh_channels: RwLock::new(HashMap::new()),
@@ -410,6 +417,7 @@ async fn get_iroh_gossip_peers(ctx: &Context, msg_id: MsgId) -> Result<Vec<NodeA
))
})
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
},
)
.await
@@ -499,13 +507,54 @@ fn create_random_topic() -> TopicId {
pub(crate) async fn create_iroh_header(ctx: &Context, msg_id: MsgId) -> Result<Header> {
let topic = create_random_topic();
insert_topic_stub(ctx, msg_id, topic).await?;
let topic_string = BASE32_NOPAD.encode(topic.as_bytes()).to_ascii_lowercase();
Ok(Header::new(
HeaderDef::IrohGossipTopic.get_headername().to_string(),
topic_string,
topic.to_string(),
))
}
async fn endpoint_loop(context: Context, endpoint: Endpoint, gossip: Gossip) {
while let Some(conn) = endpoint.accept().await {
let conn = match conn.accept() {
Ok(conn) => conn,
Err(err) => {
warn!(context, "Failed to accept iroh connection: {err:#}.");
continue;
}
};
info!(context, "IROH_REALTIME: accepting iroh connection");
let gossip = gossip.clone();
let context = context.clone();
tokio::spawn(async move {
if let Err(err) = handle_connection(&context, conn, gossip).await {
warn!(context, "IROH_REALTIME: iroh connection error: {err}");
}
});
}
}
async fn handle_connection(
context: &Context,
mut conn: iroh_net::endpoint::Connecting,
gossip: Gossip,
) -> anyhow::Result<()> {
let alpn = conn.alpn().await?;
let conn = conn.await?;
let peer_id = iroh_net::endpoint::get_remote_node_id(&conn)?;
match alpn.as_slice() {
GOSSIP_ALPN => gossip
.handle_connection(conn)
.await
.context(format!("Gossip connection to {peer_id} failed"))?,
_ => warn!(
context,
"Ignoring connection from {peer_id}: unsupported ALPN protocol"
),
}
Ok(())
}
async fn subscribe_loop(
context: &Context,
mut stream: iroh_gossip::net::GossipReceiver,
@@ -580,6 +629,7 @@ mod tests {
include_bytes!("../test-data/webxdc/minimal.xdc"),
None,
)
.await
.unwrap();
send_msg(alice, alice_chat.id, &mut instance).await.unwrap();
@@ -751,6 +801,7 @@ mod tests {
include_bytes!("../test-data/webxdc/minimal.xdc"),
None,
)
.await
.unwrap();
send_msg(alice, alice_chat.id, &mut instance).await.unwrap();
@@ -934,6 +985,7 @@ mod tests {
include_bytes!("../test-data/webxdc/minimal.xdc"),
None,
)
.await
.unwrap();
send_msg(alice, alice_chat.id, &mut instance).await.unwrap();
let alice_webxdc = alice.get_last_msg().await;

View File

@@ -711,25 +711,9 @@ impl Peerstate {
Origin::IncomingUnknownFrom,
)
.await?;
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(())
})
chat::remove_from_chat_contacts_table(context, *chat_id, contact_id)
.await?;
chat::add_to_chat_contacts_table(context, *chat_id, &[new_contact_id])
.await?;
context.emit_event(EventType::ChatModified(*chat_id));

View File

@@ -21,9 +21,11 @@ use tokio::runtime::Handle;
use crate::constants::KeyGenType;
use crate::key::{DcKey, Fingerprint};
#[allow(missing_docs)]
#[cfg(test)]
pub(crate) const HEADER_AUTOCRYPT: &str = "autocrypt-prefer-encrypt";
#[allow(missing_docs)]
pub const HEADER_SETUPCODE: &str = "passphrase-begin";
/// Preferred symmetric encryption algorithm.

View File

@@ -4,7 +4,7 @@ pub(crate) mod data;
use anyhow::Result;
use deltachat_contact_tools::EmailAddress;
use hickory_resolver::{config, Resolver, TokioResolver};
use hickory_resolver::{config, AsyncResolver, TokioAsyncResolver};
use crate::config::Config;
use crate::context::Context;
@@ -165,11 +165,11 @@ impl ProviderOptions {
/// We first try to read the system's resolver from `/etc/resolv.conf`.
/// This does not work at least on some Androids, therefore we fallback
/// to the default `ResolverConfig` which uses eg. to google's `8.8.8.8` or `8.8.4.4`.
fn get_resolver() -> Result<TokioResolver> {
if let Ok(resolver) = Resolver::tokio_from_system_conf() {
fn get_resolver() -> Result<TokioAsyncResolver> {
if let Ok(resolver) = AsyncResolver::tokio_from_system_conf() {
return Ok(resolver);
}
let resolver = Resolver::tokio(
let resolver = AsyncResolver::tokio(
config::ResolverConfig::default(),
config::ResolverOpts::default(),
);

View File

@@ -112,7 +112,7 @@ pub enum Qr {
/// Provides a backup that can be retrieved using iroh-net based backup transfer protocol.
Backup2 {
/// Iroh node address.
node_addr: iroh::NodeAddr,
node_addr: iroh_net::NodeAddr,
/// Authentication token.
auth_token: String,
@@ -644,7 +644,7 @@ fn decode_backup2(qr: &str) -> Result<Qr> {
.split_once('&')
.context("Backup QR code has no separator")?;
let auth_token = auth_token.to_string();
let node_addr = serde_json::from_str::<iroh::NodeAddr>(node_addr)
let node_addr = serde_json::from_str::<iroh_net::NodeAddr>(node_addr)
.context("Invalid node addr in backup QR code")?;
Ok(Qr::Backup2 {
@@ -1854,17 +1854,4 @@ mod tests {
Ok(())
}
/// Ensure that `DCBACKUP2` QR code does not fail to deserialize
/// because iroh changes the format of `NodeAddr`
/// as happened between iroh 0.29 and iroh 0.30 before.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_backup() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(&ctx, r#"DCBACKUP2:TWSv6ZjDPa5eoxkocj7xMi8r&{"node_id":"9afc1ea5b4f543e5cdd7b7a21cd26aee7c0b1e1c2af26790896fbd8932a06e1e","relay_url":null,"direct_addresses":["192.168.1.10:12345"]}"#).await?;
assert!(matches!(qr, Qr::Backup2 { .. }));
Ok(())
}
}

View File

@@ -187,7 +187,7 @@ mod tests {
Ok(())
}
#[expect(clippy::assertions_on_constants)]
#[allow(clippy::assertions_on_constants)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_quota_thresholds() -> anyhow::Result<()> {
assert!(QUOTA_ALLCLEAR_PERCENTAGE > 50);

View File

@@ -285,7 +285,6 @@ pub(crate) async fn set_msg_reaction(
&& msg_id.get_state(context).await?.is_outgoing()
{
context.emit_event(EventType::IncomingReaction {
chat_id,
contact_id,
msg_id,
reaction,
@@ -583,7 +582,6 @@ Here's my footer -- bob@example.net"
async fn expect_incoming_reactions_event(
t: &TestContext,
expected_chat_id: ChatId,
expected_msg_id: MsgId,
expected_contact_id: ContactId,
expected_reaction: &str,
@@ -601,12 +599,10 @@ Here's my footer -- bob@example.net"
.await;
match event {
EventType::IncomingReaction {
chat_id,
msg_id,
contact_id,
reaction,
} => {
assert_eq!(chat_id, expected_chat_id);
assert_eq!(msg_id, expected_msg_id);
assert_eq!(contact_id, expected_contact_id);
assert_eq!(reaction, Reaction::from(expected_reaction));
@@ -681,14 +677,7 @@ Here's my footer -- bob@example.net"
assert_eq!(bob_reaction.as_str(), "👍");
expect_reactions_changed_event(&alice, chat_alice.id, alice_msg.sender_msg_id, *bob_id)
.await?;
expect_incoming_reactions_event(
&alice,
chat_alice.id,
alice_msg.sender_msg_id,
*bob_id,
"👍",
)
.await?;
expect_incoming_reactions_event(&alice, alice_msg.sender_msg_id, *bob_id, "👍").await?;
expect_no_unwanted_events(&alice).await;
// Alice reacts to own message.
@@ -731,14 +720,8 @@ Here's my footer -- bob@example.net"
send_reaction(&bob, bob_msg1.id, "👍").await?;
let bob_send_reaction = bob.pop_sent_msg().await;
alice.recv_msg_trash(&bob_send_reaction).await;
expect_incoming_reactions_event(
&alice,
alice_chat.id,
alice_msg1.sender_msg_id,
alice_bob_id,
"👍",
)
.await?;
expect_incoming_reactions_event(&alice, alice_msg1.sender_msg_id, alice_bob_id, "👍")
.await?;
expect_no_unwanted_events(&alice).await;
let chatlist = Chatlist::try_load(&bob, 0, None, None).await?;

View File

@@ -1,10 +1,9 @@
//! Internet Message Format reception pipeline.
use std::collections::HashSet;
use std::iter;
use std::str::FromStr;
use anyhow::{Context as _, Result};
use data_encoding::BASE32_NOPAD;
use deltachat_contact_tools::{addr_cmp, may_be_valid_addr, sanitize_single_line, ContactAddress};
use iroh_gossip::proto::TopicId;
use mailparse::SingleInfo;
@@ -15,7 +14,7 @@ use regex::Regex;
use crate::aheader::EncryptPreference;
use crate::chat::{self, Chat, ChatId, ChatIdBlocked, ProtectionStatus};
use crate::config::Config;
use crate::constants::{Blocked, Chattype, ShowEmails, DC_CHAT_ID_TRASH};
use crate::constants::{self, 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;
@@ -26,16 +25,17 @@ 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, Message, MessageState, MessengerMessage, MsgId, Viewtype,
self, rfc724_mid_exists, rfc724_mid_exists_ex, 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};
@@ -158,7 +158,7 @@ async fn insert_tombstone(context: &Context, rfc724_mid: &str) -> Result<MsgId>
/// If `is_partial_download` is set, it contains the full message size in bytes.
/// Do not confuse that with `replace_msg_id` that will be set when the full message is loaded
/// later.
#[expect(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
pub(crate) async fn receive_imf_inner(
context: &Context,
folder: &str,
@@ -345,18 +345,6 @@ 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?;
@@ -430,7 +418,6 @@ pub(crate) async fn receive_imf_inner(
&mut mime_parser,
imf_raw,
&to_ids,
&past_ids,
rfc724_mid_orig,
from_id,
seen,
@@ -453,10 +440,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| {
recipient.addr == mime_parser.from.addr
|| mime_parser.gossiped_keys.contains_key(&recipient.addr)
})
&& mime_parser
.recipients
.iter()
.all(|recipient| mime_parser.gossiped_keys.contains_key(&recipient.addr))
{
info!(
context,
@@ -696,13 +683,12 @@ pub async fn from_field_to_contact_id(
/// Creates a `ReceivedMsg` from given parts which might consist of
/// multiple messages (if there are multiple attachments).
/// Every entry in `mime_parser.parts` produces a new row in the `msgs` table.
#[expect(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments, clippy::cognitive_complexity)]
async fn add_parts(
context: &Context,
mime_parser: &mut MimeMessage,
imf_raw: &[u8],
to_ids: &[ContactId],
past_ids: &[ContactId],
rfc724_mid: &str,
from_id: ContactId,
seen: bool,
@@ -850,7 +836,6 @@ async fn add_parts(
create_blocked,
from_id,
to_ids,
past_ids,
&verified_encryption,
&grpid,
)
@@ -921,7 +906,7 @@ async fn add_parts(
group_chat_id,
from_id,
to_ids,
past_ids,
is_partial_download.is_some(),
&verified_encryption,
)
.await?;
@@ -1090,7 +1075,6 @@ async fn add_parts(
Blocked::Not,
from_id,
to_ids,
past_ids,
&verified_encryption,
&grpid,
)
@@ -1191,7 +1175,7 @@ async fn add_parts(
chat_id,
from_id,
to_ids,
past_ids,
is_partial_download.is_some(),
&verified_encryption,
)
.await?;
@@ -1664,14 +1648,7 @@ RETURNING id
// check if any part contains a webxdc topic id
if part.typ == Viewtype::Webxdc {
if let Some(topic) = mime_parser.get_header(HeaderDef::IrohGossipTopic) {
// default encoding of topic ids is `hex`.
let mut topic_raw = [0u8; 32];
BASE32_NOPAD
.decode_mut(topic.to_ascii_uppercase().as_bytes(), &mut topic_raw)
.map_err(|e| e.error)
.context("Wrong gossip topic header")?;
let topic = TopicId::from_bytes(topic_raw);
let topic = TopicId::from_str(topic).context("wrong gossip topic header")?;
insert_topic_stub(context, *msg_id, topic).await?;
} else {
warn!(context, "webxdc doesn't have a gossip topic")
@@ -1850,7 +1827,7 @@ async fn lookup_chat_by_reply(
Ok(Some((parent_chat.id, parent_chat.blocked)))
}
#[expect(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
async fn lookup_chat_or_create_adhoc_group(
context: &Context,
mime_parser: &MimeMessage,
@@ -1894,44 +1871,34 @@ async fn lookup_chat_or_create_adhoc_group(
if !contact_ids.contains(&from_id) {
contact_ids.push(from_id);
}
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(
if let Some((chat_id, blocked)) = context
.sql
.query_row_optional(
&format!(
"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
AND add_timestamp >= remove_timestamp)=?
WHERE chat_id=c.id)=?
AND (SELECT COUNT(*) FROM chats_contacts
WHERE chat_id=c.id
AND contact_id NOT IN (SELECT id FROM temp.contacts)
AND add_timestamp >= remove_timestamp)=0
WHERE chat_id=c.id
AND contact_id NOT IN ({}))=0
ORDER BY m.timestamp DESC",
(&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? {
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?
{
info!(
context,
"Assigning message to ad-hoc group {chat_id} with matching name and members."
@@ -1995,7 +1962,7 @@ async fn is_probably_private_reply(
/// than two members, a new ad hoc group is created.
///
/// On success the function returns the created (chat_id, chat_blocked) tuple.
#[expect(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
async fn create_group(
context: &Context,
mime_parser: &mut MimeMessage,
@@ -2003,7 +1970,6 @@ 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)>> {
@@ -2077,38 +2043,14 @@ async fn create_group(
chat_id_blocked = create_blocked;
// Create initial member list.
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);
// Add all members with 0 timestamp
// because we don't know the real timestamp of their addition.
// This will allow other senders who support
// `Chat-Group-Member-Timestamps` to overwrite
// timestamps later.
let timestamp = 0;
chat::add_to_chat_contacts_table(context, timestamp, new_chat_id, &members).await?;
let mut members = vec![ContactId::SELF];
if !from_id.is_special() {
members.push(from_id);
}
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);
@@ -2133,93 +2075,21 @@ 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 trashed.
/// original system message. If the better message is empty, the original system message should be
/// just omitted.
///
/// * `to_ids` - contents of the `To` and `Cc` headers.
/// * `past_ids` - contents of the `Chat-Group-Past-Members` header.
/// * `is_partial_download` - whether the message is not fully downloaded.
#[allow(clippy::too_many_arguments)]
async fn apply_group_changes(
context: &Context,
mime_parser: &mut MimeMessage,
chat_id: ChatId,
from_id: ContactId,
to_ids: &[ContactId],
past_ids: &[ContactId],
is_partial_download: bool,
verified_encryption: &VerifiedEncryption,
) -> Result<(Vec<String>, Option<String>)> {
if chat_id.is_special() {
@@ -2234,6 +2104,7 @@ async fn apply_group_changes(
let mut send_event_chat_modified = false;
let (mut removed_id, mut added_id) = (None, None);
let mut better_msg = None;
let mut group_changes_msgs = Vec::new();
// True if a Delta Chat client has explicitly added our current primary address.
let self_added =
@@ -2243,10 +2114,53 @@ async fn apply_group_changes(
false
};
let chat_contacts =
let mut chat_contacts =
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 {
@@ -2270,24 +2184,44 @@ 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 {
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)
};
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)
};
}
} else {
warn!(context, "Removed {removed_addr:?} has no contact id.")
}
} else if let Some(added_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberAdded) {
if let Some(contact_id) =
Contact::lookup_id_by_addr(context, added_addr, Origin::Unknown).await?
{
added_id = Some(contact_id);
} else {
warn!(context, "Added {added_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;
}
better_msg = Some(stock_str::msg_add_member_local(context, added_addr, from_id).await);
if is_new_member || self_added {
better_msg =
Some(stock_str::msg_add_member_local(context, added_addr, from_id).await);
}
} else {
info!(context, "Ignoring addition of {added_addr:?} to {chat_id}.");
}
better_msg.get_or_insert_with(Default::default);
} else if let Some(old_name) = mime_parser
.get_header(HeaderDef::ChatGroupNameChanged)
.map(|s| s.trim())
@@ -2334,141 +2268,120 @@ async fn apply_group_changes(
}
}
if is_from_in_chat {
if chat.member_list_is_stale(context).await? {
info!(context, "Member list is stale.");
let mut new_members: HashSet<ContactId> = HashSet::from_iter(to_ids.iter().copied());
new_members.insert(ContactId::SELF);
if !from_id.is_special() {
new_members.insert(from_id);
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();
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);
}
context
.sql
.transaction(|transaction| {
// Remove all contacts and tombstones.
transaction.execute(
"DELETE FROM chats_contacts
WHERE chat_id=?",
(chat_id,),
)?;
// Insert contacts with default timestamps of 0.
let mut statement = transaction.prepare(
"INSERT INTO chats_contacts (chat_id, contact_id)
VALUES (?, ?)",
)?;
for contact_id in &new_members {
statement.execute((chat_id, contact_id))?;
}
Ok(())
})
.await?;
send_event_chat_modified = true;
} else if let Some(ref chat_group_member_timestamps) =
mime_parser.chat_group_member_timestamps()
{
send_event_chat_modified |= update_chats_contacts_timestamps(
context,
chat_id,
Some(from_id),
to_ids,
past_ids,
chat_group_member_timestamps,
)
.await?;
} else {
let mut new_members;
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 {
new_members = HashSet::from_iter(to_ids.iter().copied());
new_members.insert(ContactId::SELF);
if !from_id.is_special() {
new_members.insert(from_id);
}
// ... 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 {
new_members = chat_contacts.clone();
}
// Allow non-Delta Chat MUAs to add members.
if mime_parser.get_header(HeaderDef::ChatVersion).is_none() {
// Don't delete any members locally, but instead add absent ones to provide group
// membership consistency for all members:
new_members.extend(to_ids.iter());
}
// Apply explicit addition if any.
if let Some(added_id) = added_id {
new_members.insert(added_id);
}
// Apply explicit removal if any.
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?;
send_event_chat_modified = true;
added_ids = new_members.difference(&chat_contacts).copied().collect();
removed_ids = chat_contacts.difference(&new_members).copied().collect();
}
}
chat_id
.update_timestamp(
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!(
context,
Param::MemberListTimestamp,
mime_parser.timestamp_sent,
)
.await?;
}
"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,
);
}
let new_chat_contacts = HashSet::<ContactId>::from_iter(
chat::get_chat_contacts(context, chat_id)
.await?
.iter()
.copied(),
);
// These are for adding info messages about implicit membership changes.
let mut added_ids: HashSet<ContactId> = new_chat_contacts
.difference(&chat_contacts)
.copied()
.collect();
let mut removed_ids: HashSet<ContactId> = chat_contacts
.difference(&new_chat_contacts)
.copied()
.collect();
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 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;
}
chat_id
.update_timestamp(context, Param::MemberListTimestamp, ts)
.await?;
}
}
if let Some(removed_id) = removed_id {
removed_ids.remove(&removed_id);
}
let group_changes_msgs = if self_added {
Vec::new()
} else {
group_changes_msgs(context, &added_ids, &removed_ids, chat_id).await?
};
if let Some(avatar_action) = &mime_parser.group_avatar {
if !new_chat_contacts.contains(&ContactId::SELF) {
if !chat_contacts.contains(&ContactId::SELF) {
warn!(
context,
"Received group avatar update for group chat {chat_id} we are not a member of."
);
} else if !new_chat_contacts.contains(&from_id) {
} else if !chat_contacts.contains(&from_id) {
warn!(
context,
"Contact {from_id} attempts to modify group chat {chat_id} avatar without being a member.",
@@ -2500,45 +2413,6 @@ async fn apply_group_changes(
Ok((group_changes_msgs, better_msg))
}
/// Returns a list of strings that should be shown as info messages, informing about group membership changes.
async fn group_changes_msgs(
context: &Context,
added_ids: &HashSet<ContactId>,
removed_ids: &HashSet<ContactId>,
chat_id: ChatId,
) -> Result<Vec<String>> {
let mut group_changes_msgs = Vec::new();
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,
);
}
Ok(group_changes_msgs)
}
static LIST_ID_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(.+)<(.+)>$").unwrap());
fn mailinglist_header_listid(list_id_header: &str) -> Result<String> {
@@ -2607,13 +2481,7 @@ async fn create_or_lookup_mailinglist(
)
})?;
chat::add_to_chat_contacts_table(
context,
mime_parser.timestamp_sent,
chat_id,
&[ContactId::SELF],
)
.await?;
chat::add_to_chat_contacts_table(context, chat_id, &[ContactId::SELF]).await?;
Ok(Some((chat_id, blocked)))
} else {
info!(context, "Creating list forbidden by caller.");
@@ -2809,13 +2677,7 @@ async fn create_adhoc_group(
context,
"Created ad-hoc group id={new_chat_id}, name={grpname:?}."
);
chat::add_to_chat_contacts_table(
context,
mime_parser.timestamp_sent,
new_chat_id,
&member_ids,
)
.await?;
chat::add_to_chat_contacts_table(context, new_chat_id, &member_ids).await?;
context.emit_event(EventType::ChatModified(new_chat_id));
chatlist_events::emit_chatlist_changed(context);
@@ -2948,27 +2810,38 @@ 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 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;
};
for (to_addr, is_verified) in rows {
// 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? {
@@ -3066,12 +2939,14 @@ 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 = Vec::new();
let mut contact_ids = HashSet::new();
for info in address_list {
let addr = &info.addr;
if !may_be_valid_addr(addr) {
@@ -3082,14 +2957,14 @@ 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.push(contact_id);
contact_ids.insert(contact_id);
} else {
warn!(context, "Contact with address {:?} cannot exist.", addr);
}
}
Ok(contact_ids)
Ok(contact_ids.into_iter().collect::<Vec<ContactId>>())
}
#[cfg(test)]
mod receive_imf_tests;
mod tests;

View File

@@ -566,8 +566,6 @@ 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\
@@ -575,6 +573,8 @@ 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,12 +590,11 @@ 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::No);
assert_eq!(msg.text, "foo hello");
assert_eq!(msg.is_dc_message, MessengerMessage::Yes);
assert_eq!(msg.text, "hello");
assert_eq!(msg.param.get_int(Param::WantsMdn).unwrap(), 1);
}
/// 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;
@@ -613,8 +612,6 @@ 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\
@@ -623,6 +620,8 @@ 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",
@@ -1664,12 +1663,8 @@ async fn test_pdf_filename_simple() {
assert_eq!(msg.viewtype, Viewtype::File);
assert_eq!(msg.text, "mail body");
let file_path = msg.param.get(Param::File).unwrap();
assert_eq!(
file_path,
// That's the blake3 hash of the file content:
"$BLOBDIR/24a6af459cec5d733374aeaa19a6133.pdf"
);
assert_eq!(msg.param.get(Param::Filename).unwrap(), "simple.pdf");
assert!(file_path.starts_with("$BLOBDIR/simple"));
assert!(file_path.ends_with(".pdf"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -1684,8 +1679,8 @@ async fn test_pdf_filename_continuation() {
assert_eq!(msg.viewtype, Viewtype::File);
assert_eq!(msg.text, "mail body");
let file_path = msg.param.get(Param::File).unwrap();
assert!(file_path.starts_with("$BLOBDIR/"));
assert_eq!(msg.get_filename().unwrap(), "test pdf äöüß.pdf");
assert!(file_path.starts_with("$BLOBDIR/test pdf äöüß"));
assert!(file_path.ends_with(".pdf"));
}
/// HTML-images may come with many embedded images, eg. tiny icons, corners for formatting,
@@ -3248,11 +3243,11 @@ async fn test_weird_and_duplicated_filenames() -> Result<()> {
"a. tar.tar.gz",
] {
let attachment = alice.blobdir.join(filename_sent);
let content = "File content of tar.gz archive".to_string();
let content = format!("File content of {filename_sent}");
tokio::fs::write(&attachment, content.as_bytes()).await?;
let mut msg_alice = Message::new(Viewtype::File);
msg_alice.set_file_and_deduplicate(&alice, &attachment, None, None)?;
msg_alice.set_file(attachment.to_str().unwrap(), None);
let alice_chat = alice.create_chat(&bob).await;
let sent = alice.send_msg(alice_chat.id, &mut msg_alice).await;
println!("{}", sent.payload());
@@ -3266,10 +3261,9 @@ async fn test_weird_and_duplicated_filenames() -> Result<()> {
let path = msg.get_file(t).unwrap();
let path2 = path.with_file_name("saved.txt");
msg.save_file(t, &path2).await.unwrap();
assert_eq!(
path.file_name().unwrap().to_str().unwrap(),
"79402cb76f44c5761888f9036992a76.gz",
"The hash of the content should always be the same"
assert!(
path.to_str().unwrap().ends_with(".tar.gz"),
"path {path:?} doesn't end with .tar.gz"
);
assert_eq!(fs::read_to_string(&path).await.unwrap(), content);
assert_eq!(fs::read_to_string(&path2).await.unwrap(), content);
@@ -3339,7 +3333,6 @@ 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,
@@ -3549,27 +3542,26 @@ async fn test_no_private_reply_to_blocked_account() -> Result<()> {
let alice = tcm.alice().await;
let bob = tcm.bob().await;
tcm.section("Bob creates a group");
// =============== 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?;
tcm.section("Bob sends the first message to the group");
// =============== 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);
tcm.section("Bob blocks Alice");
// =============== Bob blocks Alice ================
Contact::block(&bob, bob.add_or_lookup_contact(&alice).await.id).await?;
tcm.section("Alice replies private to Bob");
// =============== Alice replies private to Bob ==============
let received = alice.get_last_msg().await;
assert_eq!(received.text, "Hello all!");
@@ -3583,7 +3575,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();
@@ -3594,7 +3586,7 @@ async fn test_no_private_reply_to_blocked_account() -> Result<()> {
let received = bob.get_last_msg().await;
assert_eq!(received.text, "Hello all!");
tcm.section("Bob unblocks Alice");
// =============== 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();
@@ -4159,15 +4151,11 @@ async fn test_ignore_outdated_membership_changes() -> Result<()> {
SystemTime::shift(Duration::from_secs(3600));
// 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.
// Bob replies again adding Alice back.
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(())
}
@@ -4228,7 +4216,7 @@ async fn test_dont_recreate_contacts_on_add_remove() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_delayed_removal_is_ignored() -> Result<()> {
async fn test_recreate_contact_list_on_missing_messages() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "Group").await?;
@@ -4236,7 +4224,6 @@ async fn test_delayed_removal_is_ignored() -> Result<()> {
// create chat with three members
add_to_chat_contacts_table(
&alice,
time(),
chat_id,
&[
Contact::create(&alice, "bob", "bob@example.net").await?,
@@ -4249,12 +4236,12 @@ async fn test_delayed_removal_is_ignored() -> Result<()> {
let bob_chat_id = bob.recv_msg(&alice.pop_sent_msg().await).await.chat_id;
bob_chat_id.accept(&bob).await?;
// Bob removes Fiona.
// bob removes a member
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 "blue" and "orange", but first addition message is lost.
// bob adds new members
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;
@@ -4262,32 +4249,32 @@ async fn test_delayed_removal_is_ignored() -> 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,
// but this results in addition of both members
// and removal of Fiona.
// alice only receives the second member addition
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);
// Alice re-adds Fiona.
// re-add 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 by Bob shouldn't remove her.
alice.recv_msg(&remove_msg).await;
// delayed removal of fiona shouldn't remove her
alice.recv_msg_trash(&remove_msg).await;
assert_eq!(get_chat_contacts(&alice, chat_id).await?.len(), 5);
alice
.golden_test_chat(chat_id, "receive_imf_delayed_removal_is_ignored")
.golden_test_chat(
chat_id,
"receive_imf_recreate_contact_list_on_missing_messages",
)
.await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_dont_readd_with_normal_msg() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let alice_chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "Group").await?;
@@ -4302,7 +4289,6 @@ 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);
@@ -4316,11 +4302,12 @@ async fn test_dont_readd_with_normal_msg() -> Result<()> {
.await?;
bob.recv_msg(&alice.pop_sent_msg().await).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);
// 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?);
Ok(())
}
@@ -4328,50 +4315,35 @@ async fn test_dont_readd_with_normal_msg() -> Result<()> {
async fn test_mua_cant_remove() -> Result<()> {
let alice = TestContext::new_alice().await;
let now = time();
// Alice creates chat with 3 contacts
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(now - 2000, 0)
.unwrap()
.to_rfc2822();
let msg = receive_imf(
&alice,
format!(
"Subject: =?utf-8?q?Message_from_alice=40example=2Eorg?=\r\n\
From: alice@example.org\r\n\
To: <bob@example.net>, <claire@example.org>, <fiona@example.org> \r\n\
Date: {date}\r\n\
Message-ID: <Mr.alices_original_mail@example.org>\r\n\
Chat-Version: 1.0\r\n\
\r\n\
tst\r\n"
)
.as_bytes(),
b"Subject: =?utf-8?q?Message_from_alice=40example=2Eorg?=\r\n\
From: alice@example.org\r\n\
To: <bob@example.net>, <claire@example.org>, <fiona@example.org> \r\n\
Date: Mon, 12 Dec 2022 14:30:39 +0000\r\n\
Message-ID: <Mr.alices_original_mail@example.org>\r\n\
Chat-Version: 1.0\r\n\
\r\n\
tst\r\n",
false,
)
.await?
.unwrap();
let alice_chat = Chat::load_from_db(&alice, msg.chat_id).await?;
assert_eq!(alice_chat.typ, Chattype::Group);
assert_eq!(alice_chat.member_list_is_stale(&alice).await?, false);
// Bob uses a classical MUA to answer, removing a recipient.
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(now - 1000, 0)
.unwrap()
.to_rfc2822();
let bob_removes = receive_imf(
&alice,
format!(
"Subject: Re: Message from alice\r\n\
From: <bob@example.net>\r\n\
To: <alice@example.org>, <claire@example.org>\r\n\
Date: {date}\r\n\
Message-ID: <bobs_answer_to_two_recipients@example.net>\r\n\
In-Reply-To: <Mr.alices_original_mail@example.org>\r\n\
\r\n\
Hi back!\r\n"
)
.as_bytes(),
b"Subject: Re: Message from alice\r\n\
From: <bob@example.net>\r\n\
To: <alice@example.org>, <claire@example.org>\r\n\
Date: Mon, 12 Dec 2022 14:32:39 +0000\r\n\
Message-ID: <bobs_answer_to_two_recipients@example.net>\r\n\
In-Reply-To: <Mr.alices_original_mail@example.org>\r\n\
\r\n\
Hi back!\r\n",
false,
)
.await?
@@ -4379,29 +4351,22 @@ async fn test_mua_cant_remove() -> Result<()> {
assert_eq!(bob_removes.chat_id, alice_chat.id);
let group_chat = Chat::load_from_db(&alice, bob_removes.chat_id).await?;
assert_eq!(group_chat.typ, Chattype::Group);
assert_eq!(group_chat.member_list_is_stale(&alice).await?, false);
assert_eq!(
chat::get_chat_contacts(&alice, group_chat.id).await?.len(),
4
);
// But if the parent message is missing, the message must goto a new ad-hoc group.
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(now, 0)
.unwrap()
.to_rfc2822();
let bob_removes = receive_imf(
&alice,
format!(
"Subject: Re: Message from alice\r\n\
From: <bob@example.net>\r\n\
To: <alice@example.org>, <claire@example.org>\r\n\
Date: {date}\r\n\
Message-ID: <bobs_answer_to_two_recipients_1@example.net>\r\n\
In-Reply-To: <Mr.missing@example.org>\r\n\
\r\n\
Hi back!\r\n"
)
.as_bytes(),
b"Subject: Re: Message from alice\r\n\
From: <bob@example.net>\r\n\
To: <alice@example.org>, <claire@example.org>\r\n\
Date: Mon, 12 Dec 2022 14:32:40 +0000\r\n\
Message-ID: <bobs_answer_to_two_recipients_1@example.net>\r\n\
In-Reply-To: <Mr.missing@example.org>\r\n\
\r\n\
Hi back!\r\n",
false,
)
.await?
@@ -4420,51 +4385,39 @@ async fn test_mua_cant_remove() -> Result<()> {
async fn test_mua_can_add() -> Result<()> {
let alice = TestContext::new_alice().await;
let now = time();
// Alice creates chat with 3 contacts
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(now - 2000, 0)
.unwrap()
.to_rfc2822();
let msg = receive_imf(
&alice,
format!(
"Subject: =?utf-8?q?Message_from_alice=40example=2Eorg?=\r\n\
From: alice@example.org\r\n\
To: <bob@example.net>, <claire@example.org>, <fiona@example.org> \r\n\
Date: {date}\r\n\
Message-ID: <Mr.alices_original_mail@example.org>\r\n\
Chat-Version: 1.0\r\n\
\r\n\
Hi!\r\n"
)
.as_bytes(),
b"Subject: =?utf-8?q?Message_from_alice=40example=2Eorg?=\r\n\
From: alice@example.org\r\n\
To: <bob@example.net>, <claire@example.org>, <fiona@example.org> \r\n\
Date: Mon, 12 Dec 2022 14:30:39 +0000\r\n\
Message-ID: <Mr.alices_original_mail@example.org>\r\n\
Chat-Version: 1.0\r\n\
\r\n\
Hi!\r\n",
false,
)
.await?
.unwrap();
let alice_chat = Chat::load_from_db(&alice, msg.chat_id).await?;
assert_eq!(alice_chat.typ, Chattype::Group);
assert_eq!(alice_chat.member_list_is_stale(&alice).await?, false);
// Bob uses a classical MUA to answer, adding a recipient.
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(now - 1000, 0)
.unwrap()
.to_rfc2822();
let bob_adds = receive_imf(
&alice,
format!("Subject: Re: Message from alice\r\n\
From: <bob@example.net>\r\n\
To: <alice@example.org>, <claire@example.org>, <fiona@example.org>, <greg@example.host>\r\n\
Date: {date}\r\n\
Message-ID: <bobs_answer_to_two_recipients@example.net>\r\n\
In-Reply-To: <Mr.alices_original_mail@example.org>\r\n\
\r\n\
Hi back!\r\n").as_bytes(),
false,
)
.await?
.unwrap();
&alice,
b"Subject: Re: Message from alice\r\n\
From: <bob@example.net>\r\n\
To: <alice@example.org>, <claire@example.org>, <fiona@example.org>, <greg@example.host>\r\n\
Date: Mon, 12 Dec 2022 14:32:39 +0000\r\n\
Message-ID: <bobs_answer_to_two_recipients@example.net>\r\n\
In-Reply-To: <Mr.alices_original_mail@example.org>\r\n\
\r\n\
Hi back!\r\n",
false,
)
.await?
.unwrap();
let group_chat = Chat::load_from_db(&alice, bob_adds.chat_id).await?;
assert_eq!(group_chat.typ, Chattype::Group);
@@ -4582,14 +4535,19 @@ 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?);
// Even if some time passed, Bob must not be re-added back.
// But if Bob left a long time ago, they must recreate the member list after missing a message.
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(())
}
@@ -4739,7 +4697,8 @@ async fn test_create_group_with_big_msg() -> Result<()> {
let bob_grp_id = create_group_chat(&bob, ProtectionStatus::Unprotected, "Group").await?;
add_contact_to_chat(&bob, bob_grp_id, ba_contact).await?;
let mut msg = Message::new(Viewtype::Image);
msg.set_file_from_bytes(&bob, "a.jpg", file_bytes, None)?;
msg.set_file_from_bytes(&bob, "a.jpg", file_bytes, None)
.await?;
let sent_msg = bob.send_msg(bob_grp_id, &mut msg).await;
assert!(!msg.get_showpadlock());
@@ -4775,7 +4734,8 @@ async fn test_create_group_with_big_msg() -> Result<()> {
let bob_grp_id = create_group_chat(&bob, ProtectionStatus::Unprotected, "Group1").await?;
add_contact_to_chat(&bob, bob_grp_id, ba_contact).await?;
let mut msg = Message::new(Viewtype::Image);
msg.set_file_from_bytes(&bob, "a.jpg", file_bytes, None)?;
msg.set_file_from_bytes(&bob, "a.jpg", file_bytes, None)
.await?;
let sent_msg = bob.send_msg(bob_grp_id, &mut msg).await;
assert!(msg.get_showpadlock());
@@ -4821,6 +4781,13 @@ 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,
@@ -4841,9 +4808,15 @@ 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.
@@ -4856,6 +4829,15 @@ 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);
@@ -4879,9 +4861,15 @@ 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(())
@@ -4904,13 +4892,19 @@ 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());
// The contact is already removed anyway.
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;
assert!(!is_contact_in_chat(alice, group_id, ContactId::SELF).await?);
Ok(())
}
@@ -4932,22 +4926,12 @@ 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());
// 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
);
assert!(!is_contact_in_chat(alice, group_id, alice_fiona_id).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.
@@ -5216,7 +5200,8 @@ async fn test_prefer_references_to_downloaded_msgs() -> Result<()> {
let file_bytes = include_bytes!("../../test-data/image/screenshot.gif");
let mut msg = Message::new(Viewtype::File);
msg.set_file_from_bytes(alice, "file", file_bytes, None)?;
msg.set_file_from_bytes(alice, "file", file_bytes, None)
.await?;
let mut sent = alice.send_msg(alice_chat_id, &mut msg).await;
sent.payload = sent
.payload
@@ -5228,7 +5213,8 @@ async fn test_prefer_references_to_downloaded_msgs() -> Result<()> {
assert_eq!(received.chat_id, bob.get_chat(alice).await.id);
let mut msg = Message::new(Viewtype::File);
msg.set_file_from_bytes(alice, "file", file_bytes, None)?;
msg.set_file_from_bytes(alice, "file", file_bytes, None)
.await?;
let sent = alice.send_msg(alice_chat_id, &mut msg).await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.download_state, DownloadState::Available);
@@ -5287,6 +5273,7 @@ async fn test_receive_vcard() -> Result<()> {
.as_bytes(),
None,
)
.await
.unwrap();
let alice_bob_chat = alice.create_chat(bob).await;
@@ -5485,100 +5472,3 @@ async fn test_prefer_chat_group_id_over_references() -> Result<()> {
assert_ne!(chat1.id, chat2.id);
Ok(())
}
/// Tests that if member timestamps are unknown
/// because of the missing `Chat-Group-Member-Timestamps` header,
/// then timestamps default to zero.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_default_member_timestamps_to_zero() -> Result<()> {
let bob = &TestContext::new_bob().await;
let now = time();
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(now - 1000, 0)
.unwrap()
.to_rfc2822();
let msg = receive_imf(
bob,
format!(
"Subject: Some group\r\n\
From: <alice@example.org>\r\n\
To: <bob@example.net>, <claire@example.org>, <fiona@example.org>\r\n\
Date: {date}\r\n\
Message-ID: <first@localhost>\r\n\
Chat-Group-ID: foobarbaz12\n\
Chat-Group-Name: foo\n\
Chat-Version: 1.0\r\n\
\r\n\
Hi!\r\n"
)
.as_bytes(),
false,
)
.await?
.unwrap();
let chat = Chat::load_from_db(bob, msg.chat_id).await?;
assert_eq!(chat.typ, Chattype::Group);
assert_eq!(chat::get_chat_contacts(bob, chat.id).await?.len(), 4);
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(now, 0)
.unwrap()
.to_rfc2822();
receive_imf(
bob,
format!(
"Subject: Some group\r\n\
From: <claire@example.org>\r\n\
To: <alice@example.org>, <bob@example.net>\r\n\
Chat-Group-Past-Members: <fiona@example.org>\r\n\
Chat-Group-Member-Timestamps: 1737783000 1737783100 1737783200\r\n\
Chat-Group-ID: foobarbaz12\n\
Chat-Group-Name: foo\n\
Chat-Version: 1.0\r\n\
Date: {date}\r\n\
Message-ID: <second@localhost>\r\n\
\r\n\
Hi back!\r\n"
)
.as_bytes(),
false,
)
.await?
.unwrap();
let chat = Chat::load_from_db(bob, msg.chat_id).await?;
assert_eq!(chat.typ, Chattype::Group);
assert_eq!(chat::get_chat_contacts(bob, chat.id).await?.len(), 3);
Ok(())
}
/// Regression test for the bug
/// that resulted in an info message
/// about Bob addition to the group on Fiona's device.
///
/// There should be no info messages about implicit
/// member changes when we are added to the group.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_two_group_securejoins() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let fiona = &tcm.fiona().await;
let group_id = chat::create_group_chat(alice, ProtectionStatus::Protected, "Group").await?;
let qr = get_securejoin_qr(alice, Some(group_id)).await?;
// Bob joins using QR code.
tcm.exec_securejoin_qr(bob, alice, &qr).await;
// Fiona joins using QR code.
tcm.exec_securejoin_qr(fiona, alice, &qr).await;
let fiona_chat_id = fiona.get_last_msg().await.chat_id;
fiona
.golden_test_chat(fiona_chat_id, "two_group_securejoins")
.await;
Ok(())
}

View File

@@ -671,10 +671,7 @@ async fn fetch_idle(
return Ok(session);
}
info!(
ctx,
"IMAP session in folder {watch_folder:?} supports IDLE, using it."
);
info!(ctx, "IMAP session supports IDLE, using it.");
let session = session
.idle(
ctx,

View File

@@ -59,13 +59,8 @@ 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,
time(),
group_chat_id,
&[invite.contact_id()],
)
.await?;
chat::add_to_chat_contacts_table(context, 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?;

View File

@@ -103,7 +103,7 @@ impl Smtp {
}
/// Connect using the provided login params.
#[expect(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
pub async fn connect(
&mut self,
context: &Context,

View File

@@ -45,7 +45,7 @@ async fn new_smtp_transport<S: AsyncBufRead + AsyncWrite + Unpin>(
Ok(transport)
}
#[expect(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
pub(crate) async fn connect_and_auth(
context: &Context,
proxy_config: &Option<ProxyConfig>,

View File

@@ -44,6 +44,12 @@ 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;
@@ -254,13 +260,9 @@ impl Sql {
let mut blob = BlobObject::new_from_path(context, avatar.as_ref()).await?;
match blob.recode_to_avatar_size(context).await {
Ok(()) => {
if let Some(path) = blob.to_abs_path().to_str() {
context
.set_config_internal(Config::Selfavatar, Some(path))
.await?;
} else {
warn!(context, "Setting selfavatar failed: non-UTF-8 filename");
}
context
.set_config_internal(Config::Selfavatar, Some(&avatar))
.await?
}
Err(e) => {
warn!(context, "Migrations can't recode avatar, removing. {:#}", e);
@@ -439,7 +441,7 @@ impl Sql {
.await
}
/// Execute the function inside a transaction assuming that it does writes.
/// Execute the function inside a transaction assuming that it does write queries.
///
/// If the function returns an error, the transaction will be rolled back. If it does not return an
/// error, the transaction will be committed.
@@ -448,28 +450,7 @@ impl Sql {
H: Send + 'static,
G: Send + FnOnce(&mut rusqlite::Transaction<'_>) -> Result<H>,
{
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| {
self.call_write(move |conn| {
let mut transaction = conn.transaction()?;
let ret = callback(&mut transaction);
@@ -1043,6 +1024,16 @@ 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::*;

View File

@@ -116,6 +116,8 @@ CREATE INDEX msgs_mdns_index1 ON msgs_mdns (msg_id);"#,
r#"
ALTER TABLE chats ADD COLUMN archived INTEGER DEFAULT 0;
CREATE INDEX chats_index2 ON chats (archived);
-- 'starred' column is not used currently
-- (dropping is not easily doable and stop adding it will make reusing it complicated)
ALTER TABLE msgs ADD COLUMN starred INTEGER DEFAULT 0;
CREATE INDEX msgs_index5 ON msgs (starred);"#,
17,
@@ -1121,7 +1123,11 @@ CREATE INDEX msgs_status_updates_index2 ON msgs_status_updates (uid);
inc_and_check(&mut migration_version, 127)?;
if dbversion < migration_version {
// This is buggy: `delete_server_after` > 1 isn't handled. Migration #129 fixes this.
// 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.
sql.execute_migration(
"INSERT OR IGNORE INTO config (keyname, value)
SELECT 'bcc_self', '1'
@@ -1132,43 +1138,6 @@ 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?
@@ -1229,35 +1198,6 @@ impl Sql {
.await
.with_context(|| format!("execute_migration failed for version {version}"))?;
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(())
self.set_db_version_in_cache(version).await
}
}

View File

@@ -1415,10 +1415,9 @@ impl Context {
// add welcome-messages. by the label, this is done only once,
// if the user has deleted the message or the chat, it is not added again.
let image = include_bytes!("../assets/welcome-image.jpg");
let blob = BlobObject::create_and_deduplicate_from_bytes(self, image, "welcome.jpg")?;
let blob = BlobObject::create(self, "welcome-image.jpg", image).await?;
let mut msg = Message::new(Viewtype::Image);
msg.param.set(Param::File, blob.as_name());
msg.param.set(Param::Filename, "welcome-image.jpg");
chat::add_device_msg(self, Some("core-welcome-image"), Some(&mut msg)).await?;
let mut msg = Message::new_text(welcome_message(self).await);

View File

@@ -286,8 +286,6 @@ impl Message {
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::chat::ChatId;
use crate::param::Param;
@@ -307,90 +305,62 @@ mod tests {
.unwrap();
let some_text = " bla \t\n\tbla\n\t".to_string();
async fn write_file_to_blobdir(d: &TestContext) -> PathBuf {
let bytes = &[38, 209, 39, 29]; // Just some random bytes
let file = d.get_blobdir().join("random_filename_392438");
tokio::fs::write(&file, bytes).await.unwrap();
file
}
let msg = Message::new_text(some_text.to_string());
assert_summary_texts(&msg, ctx, "bla bla").await; // for simple text, the type is not added to the summary
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::Image);
msg.set_file_and_deduplicate(&d, &file, Some("foo.jpg"), None)
.unwrap();
msg.set_file("foo.jpg", None);
assert_summary_texts(&msg, ctx, "📷 Image").await; // file names are not added for images
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::Image);
msg.set_text(some_text.to_string());
msg.set_file_and_deduplicate(&d, &file, Some("foo.jpg"), None)
.unwrap();
msg.set_file("foo.jpg", None);
assert_summary_texts(&msg, ctx, "📷 bla bla").await; // type is visible by emoji if text is set
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::Video);
msg.set_file_and_deduplicate(&d, &file, Some("foo.mp4"), None)
.unwrap();
msg.set_file("foo.mp4", None);
assert_summary_texts(&msg, ctx, "🎥 Video").await; // file names are not added for videos
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::Video);
msg.set_text(some_text.to_string());
msg.set_file_and_deduplicate(&d, &file, Some("foo.mp4"), None)
.unwrap();
msg.set_file("foo.mp4", None);
assert_summary_texts(&msg, ctx, "🎥 bla bla").await; // type is visible by emoji if text is set
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::Gif);
msg.set_file_and_deduplicate(&d, &file, Some("foo.gif"), None)
.unwrap();
msg.set_file("foo.gif", None);
assert_summary_texts(&msg, ctx, "GIF").await; // file names are not added for GIFs
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::Gif);
msg.set_text(some_text.to_string());
msg.set_file_and_deduplicate(&d, &file, Some("foo.gif"), None)
.unwrap();
msg.set_file("foo.gif", None);
assert_summary_texts(&msg, ctx, "GIF \u{2013} bla bla").await; // file names are not added for GIFs
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::Sticker);
msg.set_file_and_deduplicate(&d, &file, Some("foo.png"), None)
.unwrap();
msg.set_file("foo.png", None);
assert_summary_texts(&msg, ctx, "Sticker").await; // file names are not added for stickers
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::Voice);
msg.set_file_and_deduplicate(&d, &file, Some("foo.mp3"), None)
.unwrap();
msg.set_file("foo.mp3", None);
assert_summary_texts(&msg, ctx, "🎤 Voice message").await; // file names are not added for voice messages
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::Voice);
msg.set_text(some_text.clone());
msg.set_file_and_deduplicate(&d, &file, Some("foo.mp3"), None)
.unwrap();
msg.set_file("foo.mp3", None);
assert_summary_texts(&msg, ctx, "🎤 bla bla").await;
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::Audio);
msg.set_file_and_deduplicate(&d, &file, Some("foo.mp3"), None)
.unwrap();
msg.set_file("foo.mp3", None);
assert_summary_texts(&msg, ctx, "🎵 foo.mp3").await; // file name is added for audio
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::Audio);
msg.set_text(some_text.clone());
msg.set_file_and_deduplicate(&d, &file, Some("foo.mp3"), None)
.unwrap();
msg.set_file("foo.mp3", None);
assert_summary_texts(&msg, ctx, "🎵 foo.mp3 \u{2013} bla bla").await; // file name and text added for audio
let mut msg = Message::new(Viewtype::File);
let bytes = include_bytes!("../test-data/webxdc/with-minimal-manifest.xdc");
msg.set_file_from_bytes(ctx, "foo.xdc", bytes, None)
.await
.unwrap();
chat_id.set_draft(ctx, Some(&mut msg)).await.unwrap();
assert_eq!(msg.viewtype, Viewtype::Webxdc);
@@ -399,28 +369,24 @@ mod tests {
chat_id.set_draft(ctx, Some(&mut msg)).await.unwrap();
assert_summary_texts(&msg, ctx, "nice app! \u{2013} bla bla").await;
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::File);
msg.set_file_and_deduplicate(&d, &file, Some("foo.bar"), None)
.unwrap();
msg.set_file("foo.bar", None);
assert_summary_texts(&msg, ctx, "📎 foo.bar").await; // file name is added for files
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::File);
msg.set_text(some_text.clone());
msg.set_file_and_deduplicate(&d, &file, Some("foo.bar"), None)
.unwrap();
msg.set_file("foo.bar", None);
assert_summary_texts(&msg, ctx, "📎 foo.bar \u{2013} bla bla").await; // file name is added for files
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::VideochatInvitation);
msg.set_text(some_text.clone());
msg.set_file_and_deduplicate(&d, &file, Some("foo.bar"), None)
.unwrap();
msg.set_file("foo.bar", None);
assert_summary_texts(&msg, ctx, "Video chat invitation").await; // text is not added for videochat invitations
let mut msg = Message::new(Viewtype::Vcard);
msg.set_file_from_bytes(ctx, "foo.vcf", b"", None).unwrap();
msg.set_file_from_bytes(ctx, "foo.vcf", b"", None)
.await
.unwrap();
chat_id.set_draft(ctx, Some(&mut msg)).await.unwrap();
// If a vCard can't be parsed, the message becomes `Viewtype::File`.
assert_eq!(msg.viewtype, Viewtype::File);
@@ -440,6 +406,7 @@ mod tests {
END:VCARD",
None,
)
.await
.unwrap();
chat_id.set_draft(ctx, Some(&mut msg)).await.unwrap();
assert_eq!(msg.viewtype, Viewtype::Vcard);
@@ -452,11 +419,9 @@ mod tests {
assert_eq!(msg.get_summary_text(ctx).await, "Forwarded: bla bla"); // for simple text, the type is not added to the summary
assert_eq!(msg.get_summary_text_without_prefix(ctx).await, "bla bla"); // skipping prefix used for reactions summaries
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::File);
msg.set_text(some_text.clone());
msg.set_file_and_deduplicate(&d, &file, Some("foo.bar"), None)
.unwrap();
msg.set_file("foo.bar", None);
msg.param.set_int(Param::Forwarded, 1);
assert_eq!(
msg.get_summary_text(ctx).await,

View File

@@ -16,7 +16,7 @@ use crate::param::Param;
use crate::sync::SyncData::{AddQrToken, AlterChat, DeleteQrToken};
use crate::token::Namespace;
use crate::tools::time;
use crate::{message, stock_str, token};
use crate::{stock_str, token};
/// Whether to send device sync messages. Aimed for usage in the internal API.
#[derive(Debug, PartialEq)]
@@ -62,10 +62,6 @@ pub(crate) enum SyncData {
key: Config,
val: String,
},
SaveMessage {
src: String, // RFC724 id (i.e. "Message-Id" header)
dest: String, // RFC724 id (i.e. "Message-Id" header)
},
}
#[derive(Debug, Serialize, Deserialize)]
@@ -263,7 +259,6 @@ impl Context {
DeleteQrToken(token) => self.delete_qr_token(token).await,
AlterChat { id, action } => self.sync_alter_chat(id, action).await,
SyncData::Config { key, val } => self.sync_config(key, val).await,
SyncData::SaveMessage { src, dest } => self.save_message(src, dest).await,
},
SyncDataOrUnknown::Unknown(data) => {
warn!(self, "Ignored unknown sync item: {data}.");
@@ -273,15 +268,6 @@ impl Context {
.log_err(self)
.ok();
}
// Since there was a sync message, we know that there is a second device.
// Set BccSelf to true if it isn't already.
if !items.items.is_empty() && !self.get_config_bool(Config::BccSelf).await.unwrap_or(true) {
self.set_config_ex(Sync::Nosync, Config::BccSelf, Some("1"))
.await
.log_err(self)
.ok();
}
}
async fn add_qr_token(&self, token: &QrTokenData) -> Result<()> {
@@ -296,13 +282,6 @@ impl Context {
token::delete(self, Namespace::Auth, &token.auth).await?;
Ok(())
}
async fn save_message(&self, src_rfc724_mid: &str, dest_rfc724_mid: &String) -> Result<()> {
if let Some((src_msg_id, _)) = message::rfc724_mid_exists(self, src_rfc724_mid).await? {
chat::save_copy_in_self_talk(self, &src_msg_id, dest_rfc724_mid).await?;
}
Ok(())
}
}
#[cfg(test)]
@@ -599,62 +578,6 @@ mod tests {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_sync_msg_enables_bccself() -> Result<()> {
for (chatmail, sync_message_sent) in
[(false, false), (false, true), (true, false), (true, true)]
{
let alice1 = TestContext::new_alice().await;
let alice2 = TestContext::new_alice().await;
// SyncMsgs defaults to true on real devices, but in tests it defaults to false,
// so we need to enable it
alice1.set_config_bool(Config::SyncMsgs, true).await?;
alice2.set_config_bool(Config::SyncMsgs, true).await?;
if chatmail {
alice1.set_config_bool(Config::IsChatmail, true).await?;
alice2.set_config_bool(Config::IsChatmail, true).await?;
} else {
alice2.set_config_bool(Config::BccSelf, false).await?;
}
alice1.set_config_bool(Config::BccSelf, true).await?;
let sent_msg = if sync_message_sent {
alice1
.add_sync_item(SyncData::AddQrToken(QrTokenData {
invitenumber: "in".to_string(),
auth: "testtoken".to_string(),
grpid: None,
}))
.await?;
alice1.send_sync_msg().await?.unwrap();
alice1.pop_sent_sync_msg().await
} else {
let chat = alice1.get_self_chat().await;
alice1.send_text(chat.id, "Hi").await
};
// On chatmail accounts, BccSelf defaults to false.
// When receiving a sync message from another device,
// there obviously is a multi-device-setup, and BccSelf
// should be enabled.
assert_eq!(alice2.get_config_bool(Config::BccSelf).await?, false);
alice2.recv_msg_opt(&sent_msg).await;
assert_eq!(
alice2.get_config_bool(Config::BccSelf).await?,
// BccSelf should be enabled when receiving a sync message,
// but not when receiving another outgoing message
// because we might have forgotten it and it then it might have been forwarded to us again
// (though of course this is very unlikely).
sync_message_sent
);
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_bot_no_sync_msgs() -> Result<()> {
let mut tcm = TestContextManager::new();

View File

@@ -41,7 +41,6 @@ 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");
@@ -881,7 +880,7 @@ impl TestContext {
let contact = self.add_or_lookup_contact(member).await;
to_add.push(contact.id);
}
add_to_chat_contacts_table(self, time(), chat_id, &to_add)
add_to_chat_contacts_table(self, chat_id, &to_add)
.await
.unwrap();

View File

@@ -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_eq!(chats.len(), 1);
assert!(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_eq!(chats.len(), 1);
assert!(chats.len() == 1);
{
let alice_bob_chat = alice.get_chat(&bob_new).await;

View File

@@ -26,7 +26,7 @@ use chrono::{Local, NaiveDateTime, NaiveTime, TimeZone};
use deltachat_contact_tools::EmailAddress;
#[cfg(test)]
pub use deltachat_time::SystemTimeTools as SystemTime;
use futures::TryStreamExt;
use futures::{StreamExt, TryStreamExt};
use mailparse::dateparse;
use mailparse::headers::Headers;
use mailparse::MailHeaderMap;
@@ -366,6 +366,22 @@ pub(crate) async fn delete_file(context: &Context, path: impl AsRef<Path>) -> Re
Ok(())
}
pub async fn delete_files_in_dir(context: &Context, path: impl AsRef<Path>) -> Result<()> {
let read_dir = tokio::fs::read_dir(path)
.await
.context("could not read dir to delete")?;
let mut read_dir = tokio_stream::wrappers::ReadDirStream::new(read_dir);
while let Some(entry) = read_dir.next().await {
match entry {
Ok(file) => {
delete_file(context, file.file_name()).await?;
}
Err(e) => warn!(context, "Could not read file to delete: {}", e),
}
}
Ok(())
}
/// A guard which will remove the path when dropped.
///
/// It implements [`Deref`] so it can be used as a `&Path`.

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,3 @@
use std::path::Path;
use crate::chat::{send_msg, ChatId};
use crate::config::Config;
use crate::contact::ContactId;
@@ -15,7 +13,7 @@ impl Context {
pub async fn set_webxdc_integration(&self, file: &str) -> Result<()> {
let chat_id = ChatId::create_for_contact(self, ContactId::SELF).await?;
let mut msg = Message::new(Viewtype::Webxdc);
msg.set_file_and_deduplicate(self, Path::new(&file), None, None)?;
msg.set_file(file, None);
msg.hidden = true;
msg.param.set_int(Param::WebxdcIntegration, 1);
msg.param.set_int(Param::GuaranteeE2ee, 1); // needed to pass `internet_access` requirements
@@ -192,7 +190,8 @@ mod tests {
"mapstest.xdc",
include_bytes!("../../test-data/webxdc/mapstest-integration-unset.xdc"),
None,
)?;
)
.await?;
t.send_msg(self_chat.id, &mut msg).await;
assert_integration(&t, "with some icon").await?; // still the default integration
@@ -203,7 +202,8 @@ mod tests {
"mapstest.xdc",
include_bytes!("../../test-data/webxdc/mapstest-integration-set.xdc"),
None,
)?;
)
.await?;
let sent = t.send_msg(self_chat.id, &mut msg).await;
let info = msg.get_webxdc_info(&t).await?;
assert!(info.summary.contains("Used as map"));

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
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]
--------------------------------------------------------------------------------

View File

@@ -1,7 +1,8 @@
Group#Chat#10: Group chat [3 member(s)]
Group#Chat#10: Group chat [4 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: (Contact#Contact#10): What a silence! [FRESH]
Msg#13: info (Contact#Contact#Info): Member Me (bob@example.net) added. [NOTICED][INFO]
Msg#14: (Contact#Contact#10): What a silence! [FRESH]
--------------------------------------------------------------------------------

View File

@@ -5,5 +5,4 @@ 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]
--------------------------------------------------------------------------------

View File

@@ -0,0 +1,9 @@
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]
--------------------------------------------------------------------------------

View File

@@ -1,9 +0,0 @@
Group#Chat#11: Group [3 member(s)] 🛡️
--------------------------------------------------------------------------------
Msg#11: info (Contact#Contact#Info): alice@example.org invited you to join this group.
Waiting for the device of alice@example.org to reply… [NOTICED][INFO]
Msg#13: info (Contact#Contact#Info): alice@example.org replied, waiting for being added to the group… [NOTICED][INFO]
Msg#17: info (Contact#Contact#Info): Messages are guaranteed to be end-to-end encrypted from now on. [NOTICED][INFO 🛡️]
Msg#18🔒: (Contact#Contact#10): Member Me (fiona@example.net) added by alice@example.org. [FRESH][INFO]
--------------------------------------------------------------------------------