Compare commits

...
Author SHA1 Message Date
link2xt 38aaf52d9a chore: update async-imap to 0.12.0
Also upgraded base64 to 0.23 since new async-imap depends on it anyway.
2026-09-30 19:28:42 +00:00
link2xt 4e81309315 refactor: stop excluding COPYUID responses from the logs
We do not move the messages out of the inbox anymore,
so should not get these responses anyway.
2026-09-30 19:28:42 +00:00
link2xt 736979b451 feat: do not request MDNs for unencrypted messages 2026-09-29 16:02:09 +00:00
holger krekel 5a882b0aca fix: delete handled securejoin messages on all relays
A join request is sent to every relay of an inviter, but the answering
inviter device only deleted the copy it downloaded. Another device of the inviter
fetching a different relay later answered the request again, re-adding
members who had left or been removed in the meantime.
2026-09-29 14:51:29 +02:00
link2xt 1e36fb74be chore: remove some unwrap() calls 2026-09-28 15:40:37 +00:00
biørn ebf1c87483 feat: re-add "second device added" message if deleted soon after transfer (#8758)
this PR makes it easier to spot unwanted "add second device" actions, if
the unlocked device is left unattended and/or a system lock is not in
place (so cannot be checked directly before doing the "add second
device" action).

already today, the sending device gets the message "ℹ️ Profile
transferred to your second device", which, however, can be deleted.

with this PR, if the message is deleted soon after the transfer, it
first looks as if this was successful. however, the message is re-added
later, at the first housekeeping at least an hour after the transfer.
deleting the message after that is final, so users can still get rid of
it.

It is clear that this does not catch all eventualities, it is
best-effort. e.g. it is known that subsequent "add second device"
attempts remove checks of previous ones.
First line of defense is device locking and asking for secret explicitly
before adding a second device.

previous discussions about that at
https://github.com/chatmail/core/issues/4303
2026-09-28 14:49:58 +02:00
d2weber 172ebc08f9 fix: regenerate deltachat.pc if env changed (#8752)
Paths in deltachat.pc are passed via env variables to build.rs. With
this fix, the pc will be regenerated if these env variables change.

This came up when building via CMake: the paths depend on
CMAKE_INSTALL_PREFIX, if the CMAKE_INSTALL_PREFIX changes during a
reconfigure, deltachat.pc should have the corresponding paths adjusted.
2026-09-28 11:01:22 +02:00
missytake eeb9337a56 chore: add chatmail.cc (#8756) 2026-09-26 23:00:35 +02:00
holger krekel a1eb593d81 chore: add chatmail.au 2026-09-26 19:34:15 +02:00
link2xt 7e070efc28 chore: enable clippy::unnecessary_wraps 2026-09-25 11:10:23 +00:00
link2xt 5d3145d165 chore: fix clippy::string_lit_as_bytes suggestions 2026-09-25 11:10:23 +00:00
link2xt 1605b971ba chore: fix clippy::redundant_clone suggestions 2026-09-25 11:10:23 +00:00
Hocuriandl 22578ea4b6 fix: Correctly percent-encode addresses in securejoin invite codes (#8747)
This PR fixes how addresses in securejoin invite codes (i.e. QR codes
and invite links) are percent-encoded.

Before this PR,
- `@` in the addresses was percent-encoded. This makes the invite code
harder to read, and is not necessary; at least, every software we tested
correctly handled links that contain `@` (Signal, WhatsApp, Telegram,
Thunderbird, Delta Chat on Android, iOS, and Desktop)
- _But_, the first address in the `r=` parameter of a securejoin link
was not percent-encoded at all. This was a sneaky bug caused by using
the `reduce` function; I always find it hard to follow code that uses
`reduce` (and similar functions like `fold`), and apparently others have
the same problem since neither @j-g00da nor @link2xt noticed the problem
when implementing & reviewing the PR that introduced the bug.

With this PR:
- `@` is allowed in addresses in securejoin invite codes
- all addresses are percent-encoded
- `reduced` is not used anymore

---------

Co-authored-by: l <link2xt@testrun.org>
2026-09-24 20:58:11 +00:00
link2xt 076f83f320 chore: reduce noise created by key-contact migration
Stop logging the time migration takes
and don't log anything when migration runs on a fresh database
and only creates empty tables.

Without these changes every time profile is created,
the following info lines are logged:

    src/sql/migrations.rs:35: Starting key-contact transition.
    src/sql/migrations.rs:82: Not yet configured, no need to migrate key-contacts
    src/sql/migrations.rs:1942: key-contacts migration took 1.143721ms in total.
2026-09-24 16:25:22 +00:00
link2xt 8ab98019a7 feat(deltachat-repl): remove "reset" command
This is not a correct way to reset the database, it does not even clear the transports table
so account stays configured. If someone needs a fresh database, then REPL should be restarted
with a new path.
2026-09-23 12:52:17 +00:00
43 changed files with 396 additions and 262 deletions
Generated
+25 -19
View File
@@ -271,19 +271,19 @@ dependencies = [
[[package]]
name = "async-imap"
version = "0.11.3"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a6728e0f7931b36d725ac234fcb02539e9f7888dbeaaa8a18d9ea5792181570"
checksum = "f97b87216c9f0ccc63c516263169085fa34bba25633f6d96c6a9b9ca1fd5f70a"
dependencies = [
"async-channel 2.5.0",
"async-compression",
"base64",
"base64 0.23.1",
"bytes",
"chrono",
"futures",
"futures-util",
"imap-proto",
"log",
"nom 7.1.3",
"nom 8.0.0",
"pin-project",
"pin-utils",
"self_cell",
@@ -322,7 +322,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55219982f938e74491ba85dc4e49cefe8096b1e8f49348c67180a7d244988dca"
dependencies = [
"anyhow",
"base64",
"base64 0.22.1",
"futures",
"log",
"nom 8.0.0",
@@ -435,6 +435,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.6.0"
@@ -832,7 +838,7 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e"
dependencies = [
"base64",
"base64 0.22.1",
"encoding_rs",
]
@@ -1337,7 +1343,7 @@ dependencies = [
"async-native-tls",
"async-smtp",
"async_zip",
"base64",
"base64 0.23.1",
"blake3",
"brotli",
"bytes",
@@ -1439,7 +1445,7 @@ version = "2.63.0-dev"
dependencies = [
"anyhow",
"async-channel 2.5.0",
"base64",
"base64 0.23.1",
"deltachat",
"deltachat-contact-tools",
"futures",
@@ -2940,11 +2946,11 @@ dependencies = [
[[package]]
name = "imap-proto"
version = "0.16.5"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de555d9526462b6f9ece826a26fb7c67eca9a0245bd9ff84fa91972a5d5d8856"
checksum = "5ccf963d57074747b455398a1763d174da80bcaba6f51e3671a82252b531a68b"
dependencies = [
"nom 7.1.3",
"nom 8.0.0",
]
[[package]]
@@ -4180,7 +4186,7 @@ version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae"
dependencies = [
"base64",
"base64 0.22.1",
"serde",
]
@@ -4255,7 +4261,7 @@ dependencies = [
"aes-gcm",
"aes-kw",
"argon2",
"base64",
"base64 0.22.1",
"bitfields",
"block-padding",
"blowfish",
@@ -4534,7 +4540,7 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6db66007eac4a0ec8331d0d20c734bd64f6445d64bbaf0d0a27fea7a054e36"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"derive_more 1.0.0",
"futures-lite",
@@ -5090,7 +5096,7 @@ version = "0.12.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"futures-core",
"futures-util",
@@ -5696,7 +5702,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e78db9c9912c90ea7487f49bc149b329b535806bfa12b740fbade73f573a3d9f"
dependencies = [
"aes",
"base64",
"base64 0.22.1",
"blake3",
"byte_string",
"bytes",
@@ -5996,7 +6002,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb921f10397d5669e1af6455e9e2d367bf1f9cebcd6b1dd1dc50e19f6a9ac2ac"
dependencies = [
"base64",
"base64 0.22.1",
"bounded-integer",
"byteorder",
"crc",
@@ -6420,7 +6426,7 @@ version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fcaf159b4e7a376b05b5bfd77bfd38f3324f5fce751b4213bfc7eaa47affb4e"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"futures-core",
"futures-sink",
+2 -2
View File
@@ -44,7 +44,7 @@ ratelimit = { path = "./deltachat-ratelimit" }
anyhow = { workspace = true }
async-broadcast = "0.7.2"
async-channel = { workspace = true }
async-imap = { version = "0.11.3", default-features = false, features = ["runtime-tokio", "compress"] }
async-imap = { version = "0.12.0", default-features = false, features = ["runtime-tokio", "compress"] }
async-native-tls = { version = "0.6", default-features = false, features = ["runtime-tokio"] }
async-smtp = { version = "0.10.2", default-features = false, features = ["runtime-tokio"] }
async_zip = { version = "0.0.18", default-features = false, features = ["deflate", "tokio-fs"] }
@@ -180,7 +180,7 @@ harness = false
[workspace.dependencies]
anyhow = "1"
async-channel = "2.5.0"
base64 = "0.22"
base64 = "0.23"
chrono = { version = "0.4.44", default-features = false }
deltachat-contact-tools = { path = "deltachat-contact-tools" }
deltachat-jsonrpc = { path = "deltachat-jsonrpc", default-features = false }
+3 -3
View File
@@ -21,9 +21,9 @@ fn main() {
url = env::var("CARGO_PKG_HOMEPAGE").unwrap_or_else(|_| "".to_string()),
version = env::var("CARGO_PKG_VERSION").unwrap(),
libs_priv = libs_priv,
prefix = env::var("PREFIX").unwrap_or_else(|_| "/usr/local".to_string()),
libdir = env::var("LIBDIR").unwrap_or_else(|_| "/usr/local/lib".to_string()),
includedir = env::var("INCLUDEDIR").unwrap_or_else(|_| "/usr/local/include".to_string()),
prefix = option_env!("PREFIX").unwrap_or_else(|| "/usr/local"),
libdir = option_env!("LIBDIR").unwrap_or_else(|| "/usr/local/lib"),
includedir = option_env!("INCLUDEDIR").unwrap_or_else(|| "/usr/local/include"),
);
fs::create_dir_all(target_path.join("pkgconfig")).unwrap();
-59
View File
@@ -27,55 +27,6 @@ use deltachat::sql;
use deltachat::tools::*;
use tokio::fs;
/// Reset database tables.
/// Argument is a bitmask, executing single or multiple actions in one call.
/// e.g. bitmask 7 triggers actions defined with bits 1, 2 and 4.
async fn reset_tables(context: &Context, bits: i32) {
println!("Resetting tables ({bits})...");
if 0 != bits & 4 {
context
.sql()
.execute("DELETE FROM keypairs;", ())
.await
.unwrap();
println!("(4) Private keypairs reset.");
}
if 0 != bits & 8 {
context
.sql()
.execute("DELETE FROM contacts WHERE id>9;", ())
.await
.unwrap();
context
.sql()
.execute("DELETE FROM chats WHERE id>9;", ())
.await
.unwrap();
context
.sql()
.execute("DELETE FROM chats_contacts;", ())
.await
.unwrap();
context
.sql()
.execute("DELETE FROM msgs WHERE id>9;", ())
.await
.unwrap();
context
.sql()
.execute(
"DELETE FROM config WHERE keyname LIKE 'imap.%' OR keyname LIKE 'configured%';",
(),
)
.await
.unwrap();
context.sql().config_cache().write().await.clear();
println!("(8) Rest but server config reset.");
}
context.emit_msgs_changed_without_ids();
}
async fn poke_eml_file(context: &Context, filename: &Path) -> Result<()> {
let data = read_file(context, filename).await?;
@@ -304,7 +255,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
export-keys\n\
import-keys <key-file>\n\
poke [<eml-file>|<folder>|<addr> <key-file>]\n\
reset <flags>\n\
stop\n\
============================================="
),
@@ -444,15 +394,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
"poke" => {
ensure!(poke_spec(&context, Some(arg1)).await, "Poke failed");
}
"reset" => {
ensure!(
!arg1.is_empty(),
"Argument <bits> missing: 4=private keys, 8=rest but server config"
);
let bits: i32 = arg1.parse()?;
ensure!(bits < 16, "<bits> must be lower than 16.");
reset_tables(&context, bits).await;
}
"stop" => {
context.stop_ongoing().await;
}
+1 -2
View File
@@ -147,7 +147,7 @@ impl Completer for DcHelper {
}
}
const IMEX_COMMANDS: [&str; 10] = [
const IMEX_COMMANDS: [&str; 9] = [
"has-backup",
"export-backup",
"import-backup",
@@ -156,7 +156,6 @@ const IMEX_COMMANDS: [&str; 10] = [
"export-keys",
"import-keys",
"poke",
"reset",
"stop",
];
@@ -1,4 +1,5 @@
import logging
import time
import pytest
@@ -90,6 +91,30 @@ def test_qr_securejoin(acf):
fiona.wait_for_securejoin_joiner_success()
def test_qr_securejoin_request_deleted_on_all_transports(acf):
alice, bob = acf.get_online_accounts(2)
alice.add_transport_from_qr(acf.get_account_qr())
alice.bring_online()
alice_chat = alice.create_group("Group")
qr_code = alice_chat.get_qr_code()
alice2 = alice.clone()
bob.secure_join(qr_code)
alice.wait_for_securejoin_inviter_success()
alice_chat.remove_contact(bob)
# Stop and start io in order to wait until the remove message is sent out.
alice.stop_io()
alice.bring_online()
# Membership timestamps have a resolution of one second.
time.sleep(1)
# By now, the securejoin message sent by Bob must be deleted on Alice's relays.
# Otherwise, alice2 would execute securejoin again and re-add Bob.
alice2.bring_online()
alice2_chat = alice2.get_chat_by_id(alice_chat.id)
assert alice2.create_contact(bob) not in alice2_chat.get_contacts()
@pytest.mark.parametrize("all_devices_online", [True, False])
def test_qr_securejoin_broadcast(acf, all_devices_online):
alice, bob, fiona = acf.get_online_accounts(3)
+1
View File
@@ -63,6 +63,7 @@ ignore = [
# Please keep this list alphabetically sorted.
skip = [
{ name = "async-channel", version = "1.9.0" },
{ name = "base64", version = "0.22.1" },
{ name = "bitflags", version = "1.3.2" },
{ name = "constant_time_eq", version = "0.3.1" },
{ name = "cpufeatures", version = "0.2.17" },
+3 -1
View File
@@ -34,6 +34,8 @@ const DEFAULT_RELAY_CANDIDATES: &[&str] = &[
"chat.nuvon.app",
"chat.tinydispatch.org",
"chat.vim.wtf",
"chatmail.au",
"chatmail.cc",
"chatmail.uk",
"chtml.ca",
"deltachat.me",
@@ -163,7 +165,7 @@ async fn maybe_add_additional_relays_inner(context: &Context, skip_network: bool
for _ in 0..NUM_TRANSPORTS_TARGET {
if context.count_transports().await? >= NUM_TRANSPORTS_TARGET {
context
.set_config_internal(Config::AutorelayFinished, config::from_bool(true))
.set_config_internal(Config::AutorelayFinished, Some(config::from_bool(true)))
.await?;
return Ok(relay_added);
+3 -3
View File
@@ -164,9 +164,9 @@ impl<'a> BlobObject<'a> {
/// you want to create a [BlobObject] for a filename read from the
/// database.
pub fn from_name(context: &'a Context, name: &str) -> Result<BlobObject<'a>> {
let name = match name.starts_with("$BLOBDIR/") {
true => name.splitn(2, '/').last().unwrap(),
false => name,
let name = match name.strip_prefix("$BLOBDIR/") {
Some(name) => name,
None => name,
};
if !BlobObject::is_acceptible_blob_name(name) {
return Err(format_err!("not an acceptable blob name: {name}"));
+11 -4
View File
@@ -3639,7 +3639,7 @@ pub(crate) async fn create_out_broadcast_ext(
)?;
ensure!(cnt == 0, "{cnt} chats exist with grpid {grpid}");
let mut params: Params = Params::new();
params.update_timestamp(Param::GroupNameTimestamp, time())?;
params.update_timestamp(Param::GroupNameTimestamp, time());
t.execute(
"INSERT INTO chats
@@ -4795,12 +4795,14 @@ pub(crate) async fn get_chat_id_by_grpid(
///
/// Optional `label` can be provided to ensure that message is added only once.
/// If `important` is true, a notification will be sent.
/// `timestamp_sent` is the time shown on the message; it does not affect ordering.
#[expect(clippy::arithmetic_side_effects)]
pub async fn add_device_msg_with_importance(
context: &Context,
label: Option<&str>,
msg: Option<&mut Message>,
important: bool,
timestamp_sent: i64,
) -> Result<MsgId> {
ensure!(
label.is_some() || msg.is_some(),
@@ -4820,11 +4822,10 @@ pub async fn add_device_msg_with_importance(
chat_id = ChatId::get_for_contact(context, ContactId::DEVICE).await?;
let rfc724_mid = create_outgoing_rfc724_mid();
let timestamp_sent = time();
// makes sure, the added message is the last one,
// even if the date is wrong (useful esp. when warning about bad dates)
msg.timestamp_sort = timestamp_sent;
msg.timestamp_sort = time();
if let Some(last_msg_time) = chat_id.get_timestamp(context).await?
&& msg.timestamp_sort <= last_msg_time
{
@@ -4892,7 +4893,7 @@ pub async fn add_device_msg(
label: Option<&str>,
msg: Option<&mut Message>,
) -> Result<MsgId> {
add_device_msg_with_importance(context, label, msg, false).await
add_device_msg_with_importance(context, label, msg, false, time()).await
}
/// Returns true if device message with a given label was ever added to the device chat.
@@ -4920,6 +4921,12 @@ pub(crate) async fn delete_and_reset_all_device_msgs(context: &Context) -> Resul
.execute("DELETE FROM msgs WHERE from_id=?;", (ContactId::DEVICE,))
.await?;
context.sql.execute("DELETE FROM devmsglabels;", ()).await?;
context
.set_config_internal(Config::BackupTransferMsgId, None)
.await?;
context
.set_config_internal(Config::BackupTransferTimestamp, None)
.await?;
// Insert labels for welcome messages to avoid them being re-added on reconfiguration.
context
+14 -3
View File
@@ -336,6 +336,17 @@ pub enum Config {
/// Timestamp of the last time housekeeping was run
LastHousekeeping,
/// ID of the device message added after backup transfer.
///
/// If the message does not exist on the first housekeeping
/// after `READD_BACKUP_TRANSFER_MSG_DELAY`, it is re-added.
BackupTransferMsgId,
/// Timestamp of the device message added after backup transfer.
///
/// Needed to check against `READD_BACKUP_TRANSFER_MSG_DELAY`.
BackupTransferTimestamp,
/// Timestamp of the last time accumulated broadcast channel reactions were sent
LastReactionsBroadcast,
@@ -809,7 +820,7 @@ impl Context {
/// Set the given config to a boolean value.
pub async fn set_config_bool(&self, key: Config, value: bool) -> Result<()> {
self.set_config(key, from_bool(value)).await?;
self.set_config(key, Some(from_bool(value))).await?;
Ok(())
}
@@ -830,8 +841,8 @@ impl Context {
}
/// Returns a value for use in `Context::set_config_*()` for the given `bool`.
pub(crate) fn from_bool(val: bool) -> Option<&'static str> {
Some(if val { "1" } else { "0" })
pub(crate) fn from_bool(val: bool) -> &'static str {
if val { "1" } else { "0" }
}
pub(crate) fn bool_from_config(config: Option<&str>) -> bool {
+1 -1
View File
@@ -29,7 +29,7 @@ impl ServerParams {
if self.username.is_empty() {
vec![Self {
username: addr.to_string(),
..self.clone()
..self
}]
} else {
vec![self]
+2
View File
@@ -294,6 +294,8 @@ async fn test_get_info_completeness() {
// too sensitive or summarized in another item.
let skip_from_get_info = vec![
"addr",
"backup_transfer_msg_id",
"backup_transfer_timestamp",
"displayname",
"imap_certificate_checks",
"mail_server",
+14 -24
View File
@@ -1454,10 +1454,9 @@ impl Session {
/// or flags have been changed.
/// In this case we may want to skip next IDLE and do a round
/// of fetching new messages and synchronizing seen flags.
fn drain_unsolicited_responses(&self, context: &Context) -> Result<bool> {
fn drain_unsolicited_responses(&self, context: &Context) -> bool {
use UnsolicitedResponse::*;
use async_imap::imap_proto::Response;
use async_imap::imap_proto::ResponseCode;
let folder = self.selected_folder.as_deref().unwrap_or_default();
let mut should_refetch = false;
@@ -1472,34 +1471,25 @@ impl Session {
}
Expunge(_) | Recent(_) => {}
Other(ref response_data) => {
match response_data.parsed() {
Response::Fetch { .. } => {
info!(
context,
"Need to refetch {folder:?}, got unsolicited FETCH {response:?}"
);
should_refetch = true;
}
// We are not interested in the following responses and they are are
// sent quite frequently, so, we ignore them without logging them.
Response::Done {
code: Some(ResponseCode::CopyUid(_, _, _)),
..
} => {}
_ => {
info!(context, "{folder:?}: got unsolicited response {response:?}")
}
Other(ref response_data) => match response_data.parsed() {
Response::Fetch { .. } => {
info!(
context,
"Need to refetch {folder:?}, got unsolicited FETCH {response:?}"
);
should_refetch = true;
}
}
_ => {
info!(context, "{folder:?}: got unsolicited response {response:?}")
}
},
_ => {
info!(context, "{folder:?}: got unsolicited response {response:?}")
}
}
}
Ok(should_refetch)
should_refetch
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ impl Session {
self.select_with_uidvalidity(context, folder).await?;
if self.drain_unsolicited_responses(context)? {
if self.drain_unsolicited_responses(context) {
self.new_mail = true;
}
+1
View File
@@ -30,6 +30,7 @@ use crate::tools::{
mod transfer;
use ::pgp::types::KeyDetails;
pub(crate) use transfer::maybe_readd_backup_transfer_msg;
pub use transfer::{BackupProvider, get_backup};
// Name of the database file in the backup.
+97 -7
View File
@@ -40,21 +40,68 @@ use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use crate::EventType;
use crate::chat::add_device_msg;
use crate::chat::add_device_msg_with_importance;
use crate::config::Config;
use crate::context::Context;
use crate::imex::BlobDirContents;
use crate::key;
use crate::log::warn;
use crate::message::Message;
use crate::message::{Message, MsgId};
use crate::qr::Qr;
use crate::stock_str::backup_transfer_msg_body;
use crate::tools::{TempPathGuard, create_id};
use crate::tools::{TempPathGuard, create_id, time};
use super::{DBFILE_BACKUP_NAME, export_backup_stream, export_database, import_backup_stream};
/// ALPN protocol identifier for the backup transfer protocol.
const BACKUP_ALPN: &[u8] = b"/deltachat/backup";
/// Minimum time after a backup transfer before we check
/// whether the "second device added" device message still exists.
///
/// The check is done in the first housekeeping after this delay;
/// if the message was deleted until then, it is re-added with the original timestamp.
/// After the check, the user can delete the message as usual without it being re-added.
///
/// This makes it easier to spot unwanted "add second device" actions.
/// It is clear that this does not catch all eventualities, it is best-effort.
/// First line of defense is device locking and asking for secret explicitly before adding a second device.
const READD_BACKUP_TRANSFER_MSG_DELAY: i64 = 60 * 60;
/// Checks `Config::BackupTransferMsgId`
/// and re-adds the backup transfer device message if it does not exist and some time passed.
pub(crate) async fn maybe_readd_backup_transfer_msg(context: &Context) -> Result<()> {
let Some(msg_id) = context
.get_config_parsed::<u32>(Config::BackupTransferMsgId)
.await?
else {
return Ok(());
};
let timestamp = context
.get_config_i64(Config::BackupTransferTimestamp)
.await?;
if time() < timestamp.saturating_add(READD_BACKUP_TRANSFER_MSG_DELAY) {
return Ok(());
}
context
.set_config_internal(Config::BackupTransferMsgId, None)
.await?;
context
.set_config_internal(Config::BackupTransferTimestamp, None)
.await?;
if Message::load_from_db_optional(context, MsgId::new(msg_id))
.await?
.is_none()
{
let mut msg = Message::new_text(backup_transfer_msg_body(context));
add_device_msg_with_importance(context, None, Some(&mut msg), false, timestamp).await?;
}
Ok(())
}
/// Provide or send a backup of this device.
///
/// This creates a backup of the current device and starts a service which offers another
@@ -212,8 +259,23 @@ impl BackupProvider {
info!(context, "Received backup reception acknowledgement.");
context.emit_event(EventType::ImexProgress(1000));
let timestamp = time();
let mut msg = Message::new_text(backup_transfer_msg_body(&context));
add_device_msg(&context, None, Some(&mut msg)).await?;
let msg_id =
add_device_msg_with_importance(&context, None, Some(&mut msg), false, timestamp)
.await?;
context
.set_config_internal(
Config::BackupTransferTimestamp,
Some(&timestamp.to_string()),
)
.await?;
context
.set_config_internal(
Config::BackupTransferMsgId,
Some(&msg_id.to_u32().to_string()),
)
.await?;
Ok(())
}
@@ -393,14 +455,17 @@ pub async fn get_backup(context: &Context, qr: Qr) -> Result<()> {
mod tests {
use std::time::Duration;
use crate::chat::{ChatItem, get_chat_msgs, send_msg};
use crate::message::Viewtype;
use crate::chat::{ChatId, ChatItem, get_chat_msgs, send_msg};
use crate::contact::ContactId;
use crate::message::{Viewtype, delete_msgs};
use crate::sql::housekeeping;
use crate::test_utils::TestContextManager;
use crate::tools::SystemTime;
use super::*;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_receive() {
async fn test_send_receive() -> Result<()> {
let mut tcm = TestContextManager::new();
// Create first device.
@@ -472,6 +537,31 @@ mod tests {
.get_matching(|ev| matches!(ev, EventType::ImexProgress(1000)))
.await;
}
// When deleting the backup transfer message on the sending device,
// it is re-added once on housekeeping,
// but not within the first hour after the transfer.
let device_chat_id = ChatId::get_for_contact(&ctx0, ContactId::DEVICE).await?;
let original_msg = ctx0.get_last_msg_in(device_chat_id).await;
assert_eq!(original_msg.text, backup_transfer_msg_body(&ctx0));
delete_msgs(&ctx0, &[original_msg.id]).await?;
housekeeping(&ctx0).await?;
assert!(get_chat_msgs(&ctx0, device_chat_id).await?.is_empty()); // re-adding not done in first hour
SystemTime::shift(Duration::from_secs(60 * 60));
housekeeping(&ctx0).await?;
let readded_msg = ctx0.get_last_msg_in(device_chat_id).await;
assert_ne!(readded_msg.id, original_msg.id);
assert_eq!(readded_msg.get_text(), original_msg.get_text());
assert_eq!(readded_msg.get_timestamp(), original_msg.get_timestamp());
assert!(readded_msg.get_sort_timestamp() > original_msg.get_sort_timestamp());
delete_msgs(&ctx0, &[readded_msg.id]).await?;
housekeeping(&ctx0).await?;
let device_msgs = get_chat_msgs(&ctx0, device_chat_id).await?;
assert!(device_msgs.is_empty()); // re-adding is done only once
Ok(())
}
/// Tests that trying to accidentally overwrite a profile
+2 -1
View File
@@ -98,7 +98,8 @@ pub trait DcKey: Serialize + Deserializable + Clone {
// Because we write to a Vec<u8> the io::Write impls never
// fail and we can hide this error.
let mut buf = Vec::new();
self.to_writer(&mut buf).unwrap();
self.to_writer(&mut buf)
.expect("Writing to Vec<u8> cannot fail");
buf
}
+2 -1
View File
@@ -15,7 +15,8 @@
clippy::explicit_iter_loop,
clippy::explicit_into_iter_loop,
clippy::cloned_instead_of_copied,
clippy::manual_is_variant_and
clippy::manual_is_variant_and,
clippy::unnecessary_wraps
)]
#![cfg_attr(not(test), warn(clippy::arithmetic_side_effects))]
#![cfg_attr(not(test), forbid(clippy::indexing_slicing))]
+13 -8
View File
@@ -580,7 +580,7 @@ impl Message {
if let Some(msg) = &mut msg {
msg.additional_text =
Self::get_additional_text(context, msg.download_state, &msg.param)?;
Self::get_additional_text(context, msg.download_state, &msg.param);
}
Ok(msg)
@@ -618,7 +618,7 @@ impl Message {
context: &Context,
download_state: DownloadState,
param: &Params,
) -> Result<String> {
) -> String {
if download_state != DownloadState::Done {
let file_size = param
.get(Param::PostMessageFileBytes)
@@ -635,14 +635,14 @@ impl Message {
.unwrap_or("?".to_owned());
return match viewtype {
Viewtype::File => Ok(format!(" [{file_name} – {file_size}]")),
Viewtype::File => format!(" [{file_name} – {file_size}]"),
_ => {
let translated_viewtype = viewtype.to_locale_string(context);
Ok(format!(" [{translated_viewtype} – {file_size}]"))
format!(" [{translated_viewtype} – {file_size}]")
}
};
}
Ok(String::new())
String::new()
}
/// Returns the MIME type of an attached file if it exists.
@@ -2017,14 +2017,19 @@ pub(crate) async fn set_msg_failed(
/// Inserts a tombstone into `msgs` table
/// to prevent downloading the same message in the future.
/// With `on_server`, copies seen on IMAP later are deleted on the server.
///
/// Returns tombstone database row ID.
pub(crate) async fn insert_tombstone(context: &Context, rfc724_mid: &str) -> Result<MsgId> {
pub(crate) async fn insert_tombstone(
context: &Context,
rfc724_mid: &str,
on_server: bool,
) -> Result<MsgId> {
let row_id = context
.sql
.insert(
"INSERT INTO msgs(rfc724_mid, chat_id) VALUES (?,?)",
(rfc724_mid, ChatId::TRASH),
"INSERT INTO msgs(rfc724_mid, chat_id, deleted) VALUES (?,?,?)",
(rfc724_mid, ChatId::TRASH, on_server),
)
.await?;
let msg_id = MsgId::new(u32::try_from(row_id)?);
+1 -3
View File
@@ -784,7 +784,7 @@ async fn test_get_existing_msg_ids() -> Result<()> {
}
#[test]
fn test_can_fail() -> Result<()> {
fn test_can_fail() {
use MessageState::*;
// states that are not allowed to transition to OutFailed
@@ -799,6 +799,4 @@ fn test_can_fail() -> Result<()> {
assert!(OutPending.can_fail());
assert!(OutDelivered.can_fail());
assert!(OutFailed.can_fail());
Ok(())
}
+6 -5
View File
@@ -511,11 +511,6 @@ impl MimeFactory {
let mut member_fingerprints = Vec::new();
let mut member_timestamps = Vec::new();
let mut recipient_ids = HashSet::new();
let req_mdn = !chat.is_self_talk()
&& !msg.is_system_message()
&& msg.param.get_int(Param::Reaction).unwrap_or_default() == 0
&& context.should_request_mdns().await?;
let self_fingerprint = self_fingerprint(context).await?;
let encryption = if chat.is_self_talk() {
@@ -796,6 +791,12 @@ impl MimeFactory {
}
};
let req_mdn = encryption.is_encrypted()
&& !chat.is_self_talk()
&& !msg.is_system_message()
&& msg.param.get_int(Param::Reaction).unwrap_or_default() == 0
&& context.should_request_mdns().await?;
let (in_reply_to, references) = context
.sql
.query_row(
+2 -4
View File
@@ -795,7 +795,7 @@ async fn test_protected_headers_directive() -> Result<()> {
// 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", b"content", None)?;
let sent = bob.send_msg(chat, &mut msg).await;
assert!(msg.get_showpadlock());
assert!(sent.payload.contains("\r\nSubject: [...]\r\n"));
@@ -1100,7 +1100,6 @@ To: <bob@example.net>
Subject: Message from alice@example.org
References: <MESSAGE_ID@localhost>
Chat-Version: 1.0
Chat-Disposition-Notification-To: alice@example.org
Content-Transfer-Encoding: 7bit
Hello!"#
@@ -1127,7 +1126,7 @@ async fn test_render_unencrypted_msg_with_attachment() -> Result<()> {
.await;
let mut msg = Message::new(Viewtype::File);
msg.set_text("Hello!".to_string());
msg.set_file_from_bytes(alice, "foo.bar", "content".as_bytes(), None)?;
msg.set_file_from_bytes(alice, "foo.bar", b"content", None)?;
let sent = alice.send_msg(chat.id, &mut msg).await;
let unencrypted = normalized_payload(sent).await;
@@ -1152,7 +1151,6 @@ To: <bob@example.net>
Subject: Message from alice@example.org
References: <MESSAGE_ID@localhost>
Chat-Version: 1.0
Chat-Disposition-Notification-To: alice@example.org
--BOUNDARY
+7 -10
View File
@@ -294,11 +294,7 @@ impl MimeMessage {
&mut wants_mdn,
&mail,
);
headers_removed.extend(
headers
.extract_if(|k, _v| is_hidden(k))
.map(|(k, _v)| k.to_string()),
);
headers_removed.extend(headers.extract_if(|k, _v| is_hidden(k)).map(|(k, _v)| k));
// Parse hidden headers.
let mimetype = mail.ctype.mimetype.parse::<Mime>()?;
@@ -935,9 +931,10 @@ impl MimeMessage {
// See if an MDN is requested from the other side
if self.decryption_error.is_none()
&& (!self.parts.is_empty() || matches!(&self.pre_message, PreMessageMode::Pre { .. }))
&& self.wants_mdn
&& self.incoming
&& let Some(part) = self.parts.last_mut()
&& self.wants_mdn
&& self.was_encrypted() // Do not send MDNs for unencrypted messages.
&& self.incoming
&& let Some(part) = self.parts.last_mut()
{
part.param.set_int(Param::WantsMdn, 1);
}
@@ -951,7 +948,7 @@ impl MimeMessage {
typ: Viewtype::Text,
..Default::default()
};
if self.wants_mdn && self.incoming {
if self.wants_mdn && self.was_encrypted() && self.incoming {
part.param.set_int(Param::WantsMdn, 1);
}
if let Some(ref subject) = self.get_subject()
@@ -1751,7 +1748,7 @@ impl MimeMessage {
headers_removed.extend(
headers
.extract_if(|k, _v| has_header_protection || is_protected(k))
.map(|(k, _v)| k.to_string()),
.map(|(k, _v)| k),
);
if has_header_protection {
+1 -1
View File
@@ -46,7 +46,7 @@ use crate::mimeparser::SystemMessage;
/// The length of an ed25519 `PublicKey`, in bytes.
const PUBLIC_KEY_LENGTH: usize = 32;
const PUBLIC_KEY_STUB: &[u8] = "static_string".as_bytes();
const PUBLIC_KEY_STUB: &[u8] = b"static_string";
/// Store Iroh peer channels for the context.
#[derive(Debug)]
+10 -10
View File
@@ -86,14 +86,14 @@ async fn test_can_communicate() {
.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(alice, alice_webxdc.id, "alice -> bob".as_bytes().to_vec())
.send_webxdc_realtime_data(alice, alice_webxdc.id, b"alice -> bob".to_vec())
.await
.unwrap();
loop {
let event = bob.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == "alice -> bob".as_bytes() {
if data == b"alice -> bob" {
break;
} else {
panic!(
@@ -107,14 +107,14 @@ async fn test_can_communicate() {
bob.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(bob, bob_webxdc.id, "bob -> alice".as_bytes().to_vec())
.send_webxdc_realtime_data(bob, bob_webxdc.id, b"bob -> alice".to_vec())
.await
.unwrap();
loop {
let event = alice.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == "bob -> alice".as_bytes() {
if data == b"bob -> alice" {
break;
} else {
panic!(
@@ -149,14 +149,14 @@ async fn test_can_communicate() {
bob.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(bob, bob_webxdc.id, "bob -> alice 2".as_bytes().to_vec())
.send_webxdc_realtime_data(bob, bob_webxdc.id, b"bob -> alice 2".to_vec())
.await
.unwrap();
loop {
let event = alice.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == "bob -> alice 2".as_bytes() {
if data == b"bob -> alice 2" {
break;
} else {
panic!(
@@ -314,14 +314,14 @@ async fn test_can_reconnect() {
.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(alice, alice_webxdc.id, "alice -> bob".as_bytes().to_vec())
.send_webxdc_realtime_data(alice, alice_webxdc.id, b"alice -> bob".to_vec())
.await
.unwrap();
loop {
let event = bob.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == "alice -> bob".as_bytes() {
if data == b"alice -> bob" {
break;
} else {
panic!(
@@ -373,14 +373,14 @@ async fn test_can_reconnect() {
bob.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(bob, bob_webxdc.id, "bob -> alice".as_bytes().to_vec())
.send_webxdc_realtime_data(bob, bob_webxdc.id, b"bob -> alice".to_vec())
.await
.unwrap();
loop {
let event = alice.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == "bob -> alice".as_bytes() {
if data == b"bob -> alice" {
break;
} else {
panic!(
+1 -1
View File
@@ -401,7 +401,7 @@ pub fn merge_openpgp_certificates(
// such as Alice's key in `test-data/key/alice-secret.asc`.
let best_user: Option<SignedUser> = old_users
.into_iter()
.chain(new_users.clone())
.chain(new_users)
.filter_map(|SignedUser { id, signatures }| {
// Select the best signature for each User ID.
// If User ID has no valid signatures, it is filtered out.
+1 -1
View File
@@ -384,7 +384,7 @@ fn test_merge_openpgp_certificates() {
// Cannot merge certificates with different primary key.
assert!(merge_openpgp_certificates(alice.clone(), bob.clone()).is_err());
assert!(merge_openpgp_certificates(bob.clone(), alice.clone()).is_err());
assert!(merge_openpgp_certificates(bob, alice).is_err());
}
/// Test PQC support.
+5 -9
View File
@@ -1,6 +1,6 @@
//! Handle plain text together with some attributes.
use std::sync::LazyLock;
use regex::regex;
use crate::simplify::remove_message_footer;
@@ -25,12 +25,8 @@ impl PlainText {
/// Convert plain text to HTML.
/// The function handles quotes, links, fixed and floating text paragraphs.
pub fn to_html(&self) -> String {
static LINKIFY_MAIL_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\b([\w.\-+]+@[\w.\-]+)\b").unwrap());
static LINKIFY_URL_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"\b((http|https|ftp|ftps):[\w.,:;$/@!?&%\-~=#+]+)").unwrap()
});
let linkify_mail_re = regex!(r"\b([\w.\-+]+@[\w.\-]+)\b");
let linkify_url_re = regex!(r"\b((http|https|ftp|ftps):[\w.,:;$/@!?&%\-~=#+]+)");
let lines: Vec<&str> = self.text.lines().collect();
let (lines, _footer) = remove_message_footer(&lines);
@@ -52,12 +48,12 @@ impl PlainText {
// to avoid double encoding, we escape our html-entities by \r that must not be used in the string elsewhere.
let line = line.to_string().replace('\r', "");
let mut line = LINKIFY_MAIL_RE
let mut line = linkify_mail_re
.replace_all(&line, "\rLTa href=\rQUOTmailto:$1\rQUOT\rGT$1\rLT/a\rGT")
.as_ref()
.to_string();
line = LINKIFY_URL_RE
line = linkify_url_re
.replace_all(&line, "\rLTa href=\rQUOT$1\rQUOT\rGT$1\rLT/a\rGT")
.as_ref()
.to_string();
+8 -10
View File
@@ -2,13 +2,13 @@
mod dclogin_scheme;
use std::collections::BTreeMap;
use std::sync::LazyLock;
use anyhow::{Context as _, Result, anyhow, bail, ensure};
pub use dclogin_scheme::LoginOptions;
pub(crate) use dclogin_scheme::login_param_from_login_qr;
use deltachat_contact_tools::{ContactAddress, addr_normalize, may_be_valid_addr};
use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, percent_encode};
use regex::regex;
use serde::Deserialize;
use crate::autorelay::login_param_from_host;
@@ -764,7 +764,7 @@ fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result<Qr> {
fn decode_shadowsocks_proxy(qr: &str) -> Result<Qr> {
let server_config = shadowsocks::config::ServerConfig::from_url(qr)?;
let addr = server_config.addr();
let host = addr.host().to_string();
let host = addr.host();
let port = addr.port();
Ok(Qr::Proxy {
url: qr.to_string(),
@@ -1071,16 +1071,11 @@ async fn decode_matmsg(context: &Context, qr: &str) -> Result<Qr> {
Qr::from_address(context, name, &addr, None).await
}
static VCARD_NAME_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(?m)^N:([^;]*);([^;\n]*)").unwrap());
static VCARD_EMAIL_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(?m)^EMAIL([^:\n]*):([^;\n]*)").unwrap());
/// Extract address for the vcard scheme.
///
/// Scheme: `VCARD:BEGIN\nN:last name;first name;...;\nEMAIL;<type>:addr...;`
async fn decode_vcard(context: &Context, qr: &str) -> Result<Qr> {
let name = VCARD_NAME_RE
let name = regex!(r"(?m)^N:([^;]*);([^;\n]*)")
.captures(qr)
.and_then(|caps| {
let last_name = caps.get(1)?.as_str().trim();
@@ -1090,7 +1085,10 @@ async fn decode_vcard(context: &Context, qr: &str) -> Result<Qr> {
})
.unwrap_or_default();
let addr = if let Some(cap) = VCARD_EMAIL_RE.captures(qr).and_then(|caps| caps.get(2)) {
let addr = if let Some(cap) = regex!(r"(?m)^EMAIL([^:\n]*):([^;\n]*)")
.captures(qr)
.and_then(|caps| caps.get(2))
{
normalize_address(cap.as_str().trim())?
} else {
bail!("Bad e-mail address");
@@ -1124,7 +1122,7 @@ fn normalize_address(addr: &str) -> Result<String> {
ensure!(may_be_valid_addr(&new_addr), "Bad e-mail address");
Ok(new_addr.to_string())
Ok(new_addr)
}
#[cfg(test)]
+1 -1
View File
@@ -120,7 +120,7 @@ pub(super) fn decode_login(qr: &str) -> Result<Qr> {
};
Ok(Qr::Login {
address: addr.to_owned(),
address: addr,
options,
})
} else {
+1 -1
View File
@@ -165,7 +165,7 @@ async fn set_msg_id_reaction(
.await?;
if chat
.param
.update_timestamp(Param::LastReactionTimestamp, timestamp)?
.update_timestamp(Param::LastReactionTimestamp, timestamp)
{
chat.param
.set_i64(Param::LastReactionMsgId, i64::from(msg_id.to_u32()));
+7 -6
View File
@@ -483,7 +483,7 @@ pub(crate) async fn receive_imf_inner(
}
let trash = || async {
let msg_ids = vec![insert_tombstone(context, rfc724_mid).await?];
let msg_ids = vec![insert_tombstone(context, rfc724_mid, false).await?];
Ok(Some(ReceivedMsg {
chat_id: ChatId::TRASH,
state: MessageState::Undefined,
@@ -668,14 +668,15 @@ pub(crate) async fn receive_imf_inner(
match res {
securejoin::HandshakeMessage::Done | securejoin::HandshakeMessage::Ignore => {
let msg_id = insert_tombstone(context, rfc724_mid).await?;
let needs_delete_job = res == securejoin::HandshakeMessage::Done;
let msg_id = insert_tombstone(context, rfc724_mid, needs_delete_job).await?;
received_msg = Some(ReceivedMsg {
chat_id: ChatId::TRASH,
state: MessageState::InSeen,
hidden: false,
sort_timestamp: mime_parser.timestamp_sent,
msg_ids: vec![msg_id],
needs_delete_job: res == securejoin::HandshakeMessage::Done,
needs_delete_job,
});
}
securejoin::HandshakeMessage::Propagate => {
@@ -2311,7 +2312,7 @@ INSERT INTO msgs
// This way, `LastSubject` actually refers to the most recent message _shown_ in the chat.
if chat
.param
.update_timestamp(Param::SubjectTimestamp, sort_timestamp)?
.update_timestamp(Param::SubjectTimestamp, sort_timestamp)
{
// write the last subject even if empty -
// otherwise a reply may get an outdated subject.
@@ -2401,7 +2402,7 @@ async fn handle_edit_delete(
let Some(msg_id) = message::rfc724_mid_exists(context, rfc724_mid).await? else {
warn!(context, "Delete message: {rfc724_mid:?} not found.");
// Insert a tombstone so that the message will be ignored if it arrives later within a period specified in prune_tombstones().
insert_tombstone(context, rfc724_mid).await?;
insert_tombstone(context, rfc724_mid, false).await?;
continue;
};
@@ -3414,7 +3415,7 @@ async fn apply_chat_name_avatar_and_description_changes(
&& is_from_in_chat
&& chat
.param
.update_timestamp(Param::AvatarTimestamp, mime_parser.timestamp_sent)?
.update_timestamp(Param::AvatarTimestamp, mime_parser.timestamp_sent)
{
info!(context, "Group-avatar change for {}.", chat.id);
match avatar_action {
+3 -1
View File
@@ -416,7 +416,9 @@ async fn test_escaped_from() {
);
let msg = get_chat_msg(&t, chat_id, 0, 1).await;
assert_eq!(msg.text, "hello");
assert_eq!(msg.param.get_int(Param::WantsMdn).unwrap(), 1);
// MDN request from unencrypted message is ignored.
assert!(msg.param.get_int(Param::WantsMdn).is_none());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+14 -15
View File
@@ -33,14 +33,14 @@ pub(crate) use qrinvite::QrInvite;
use crate::token::Namespace;
const DISALLOWED_CHARACTERS: &AsciiSet = &NON_ALPHANUMERIC_WITHOUT_DOT.remove(b'_');
const DISALLOWED_CHARACTERS: &AsciiSet = &NON_ALPHANUMERIC_WITHOUT_DOT.remove(b'_').remove(b'@');
fn inviter_progress(
context: &Context,
contact_id: ContactId,
chat_id: ChatId,
chat_type: Chattype,
) -> Result<()> {
) {
// No other values are used.
let progress = 1000;
context.emit_event(EventType::SecurejoinInviterProgress {
@@ -49,8 +49,6 @@ fn inviter_progress(
chat_type,
progress,
});
Ok(())
}
/// Shorten name to max. `length` characters.
@@ -124,18 +122,19 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option<ChatId>) -> Resul
let self_addr = context.get_primary_self_addr().await?;
let self_addr_urlencoded = utf8_percent_encode(&self_addr, DISALLOWED_CHARACTERS).to_string();
let r_param = context
let encoded_extra_relays: Vec<String> = context
.get_self_addrs()
.await?
.into_iter()
.filter(|addr| *addr != self_addr)
.reduce(|acc, addr| {
format!(
"{acc},{}",
utf8_percent_encode(&addr, DISALLOWED_CHARACTERS)
)
})
.map_or(String::default(), |addrs| format!("&r={addrs}"));
.map(|addr| utf8_percent_encode(&addr, DISALLOWED_CHARACTERS).to_string())
.collect();
let r_param = if encoded_extra_relays.is_empty() {
"".to_string()
} else {
format!("&r={}", encoded_extra_relays.join(","))
};
let self_name = context
.get_config(Config::Displayname)
@@ -659,7 +658,7 @@ pub(crate) async fn handle_securejoin_handshake(
context.emit_event(EventType::ContactsChanged(Some(contact_id)));
}
inviter_progress(context, contact_id, joining_chat_id, chat.typ)?;
inviter_progress(context, contact_id, joining_chat_id, chat.typ);
// IMAP-delete the message to avoid handling it by another device and adding the
// member twice. Another device will know the member's key from Autocrypt-Gossip.
Ok(HandshakeMessage::Done)
@@ -670,7 +669,7 @@ pub(crate) async fn handle_securejoin_handshake(
.await
.context("failed sending vc-contact-confirm message")?;
inviter_progress(context, contact_id, chat_id, Chattype::Single)?;
inviter_progress(context, contact_id, chat_id, Chattype::Single);
Ok(HandshakeMessage::Ignore) // "Done" would delete the message and break multi-device (the key from Autocrypt-header is needed)
}
}
@@ -817,7 +816,7 @@ pub(crate) async fn observe_securejoin_on_other_device(
// and tests which don't care about the chat ID,
// so we pass invalid chat ID here.
let chat_id = ChatId::new(0);
inviter_progress(context, contact_id, chat_id, chat_type)?;
inviter_progress(context, contact_id, chat_id, chat_type);
}
if matches!(step, SecureJoinStep::MemberAdded) {
+65
View File
@@ -6,6 +6,7 @@ use crate::chat::{CantSendReason, ChatId, add_contact_to_chat, remove_contact_fr
use crate::chatlist::Chatlist;
use crate::constants::Chattype;
use crate::key::self_fingerprint;
use crate::message::rfc724_mid_exists_ext;
use crate::qr::Qr;
use crate::receive_imf::receive_imf;
use crate::stock_str::{self, messages_e2ee_info_msg};
@@ -13,6 +14,7 @@ use crate::test_utils::{
AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, TestContext, TestContextManager,
TimeShiftFalsePositiveNote, get_chat_msg, sync,
};
use crate::transport::add_pseudo_transport;
#[derive(PartialEq)]
enum SetupContactCase {
@@ -1142,6 +1144,43 @@ async fn test_get_securejoin_qr_name_is_last() -> Result<()> {
Ok(())
}
/// Test that addresses in QR codes are percent-encoded.
/// `@` should not be encoded unnecessarily,
/// since this would just make the QR code longer.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_securejoin_qr_encoding() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
// `@` in email addresses must not be percent-encoded:
add_pseudo_transport(alice, "asdf@example.org").await?;
// But `%` does need percent-encoding:
add_pseudo_transport(alice, "jk%l@example.net").await?;
let qr = get_securejoin_qr(alice, None).await?;
assert!(
qr.contains("a=alice@example.org"),
"{qr} doesn't contain 'a=alice@example.org'"
);
assert!(
qr.contains("r=jk%25l@example.net,asdf@example.org"),
"{qr} doesn't contain 'r=jk%25l@example.net,asdf@example.org'"
);
let qr = check_qr(bob, &qr).await?;
let Qr::AskVerifyContact { mut addrs, .. } = qr else {
unreachable!()
};
addrs.sort();
assert_eq!(
addrs,
vec!["alice@example.org", "asdf@example.org", "jk%l@example.net",]
);
Ok(())
}
/// QR codes should not get arbitrary big because of long names.
/// The truncation, however, should not let the url end with a `.`, which is a call for trouble in linkfiers.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -1525,3 +1564,29 @@ async fn test_deduplicate_member_added() -> Result<()> {
Ok(())
}
/// Tests that a handled join request is also marked as "deleted"
/// in the database, so that if a copy of the request arrives via
/// other relays in the future, then this copy will also be deleted.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_join_request_deleted_on_all_relays() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat_id = chat::create_group(alice, "Group").await?;
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
bob.add_or_lookup_contact_id(alice).await;
join_securejoin(bob, &qr).await?;
let request = bob.pop_sent_msg().await;
alice.recv_msg_trash(&request).await;
let rfc724_mid = Message::load_from_db(bob, request.sender_msg_id)
.await?
.rfc724_mid;
let (_, deleted) = rfc724_mid_exists_ext(alice, &rfc724_mid, "deleted=1")
.await?
.unwrap();
assert!(deleted);
Ok(())
}
+2 -2
View File
@@ -356,7 +356,7 @@ pub(crate) async fn insert_into_smtp(
queued_msg: &QueuedMail,
) -> Result<()> {
let now = tools::time();
let msg_id = message::insert_tombstone(context, rfc724_mid).await?;
let msg_id = message::insert_tombstone(context, rfc724_mid, false).await?;
context
.sql
.transaction(|transaction| queue::enqueue_mail(transaction, now, msg_id, queued_msg, None))
@@ -720,7 +720,7 @@ async fn send_mdn_rfc724_mid(
.ok()
})
.collect();
message::insert_tombstone(context, &rendered_msg.rfc724_mid).await?;
message::insert_tombstone(context, &rendered_msg.rfc724_mid, false).await?;
match smtp_send(context, &recipients, &body, smtp, None).await {
SendResult::Success => {
if !recipients.is_empty() {
+7 -1
View File
@@ -14,7 +14,7 @@ use crate::config::Config;
use crate::context::Context;
use crate::debug_logging::set_debug_logging_xdc;
use crate::ephemeral::start_ephemeral_timers;
use crate::imex::BLOBS_BACKUP_NAME;
use crate::imex::{self, BLOBS_BACKUP_NAME};
use crate::location;
use crate::log::{LogExt, warn};
use crate::message::MsgId;
@@ -827,6 +827,12 @@ pub async fn housekeeping(context: &Context) -> Result<()> {
);
}
imex::maybe_readd_backup_transfer_msg(context)
.await
.context("Failed to re-add backup transfer message")
.log_err(context)
.ok();
if let Err(err) = incremental_vacuum(context).await {
warn!(context, "Failed to run incremental vacuum: {err:#}.");
}
+4 -12
View File
@@ -32,8 +32,6 @@ fn migrate_key_contacts(
context: &Context,
transaction: &mut rusqlite::Transaction<'_>,
) -> std::result::Result<(), anyhow::Error> {
info!(context, "Starting key-contact transition.");
// =============================== Step 1: ===============================
// Alter tables
transaction.execute_batch(
@@ -79,13 +77,12 @@ fn migrate_key_contacts(
.optional()
.context("Step 0")?
else {
info!(
context,
"Not yet configured, no need to migrate key-contacts"
);
// Not yet configured, no need to migrate key-contacts.
return Ok(());
};
info!(context, "Starting key-contact transition.");
// =============================== Step 2: ===============================
// Create up to 3 new contacts for every contact that has a peerstate:
// one from the Autocrypt key fingerprint, one from the verified key fingerprint,
@@ -1936,14 +1933,9 @@ CREATE INDEX gossip_timestamp_index ON gossip_timestamp (chat_id, fingerprint);
inc_and_check(&mut migration_version, 132)?;
if dbversion < migration_version {
let start = Time::now();
sql.execute_migration_transaction(|t| migrate_key_contacts(context, t), migration_version)
.await?;
info!(
context,
"key-contacts migration took {:?} in total.",
time_elapsed(&start),
);
// Schedule `msgs_to_key_contacts()`.
context
.set_config_internal(Config::LastHousekeeping, None)
+1
View File
@@ -220,6 +220,7 @@ async fn maybe_warn_on_bad_time(context: &Context, now: i64, known_past_timestam
),
Some(&mut msg),
true,
time(),
)
.await
.ok();
+13 -13
View File
@@ -27,7 +27,7 @@ impl Context {
Ok(param.parse().unwrap_or_default())
},
)?;
let update = param.update_timestamp(scope, new_timestamp)?;
let update = param.update_timestamp(scope, new_timestamp);
if update {
transaction.execute(
"UPDATE contacts SET param=? WHERE id=?",
@@ -57,7 +57,7 @@ impl ChatId {
let param: String = row.get(0)?;
Ok(param.parse().unwrap_or_default())
})?;
let update = param.update_timestamp(scope, new_timestamp)?;
let update = param.update_timestamp(scope, new_timestamp);
if update {
transaction.execute(
"UPDATE chats SET param=? WHERE id=?",
@@ -73,13 +73,13 @@ impl ChatId {
impl Params {
/// Updates a param's timestamp in memory, if reasonable.
/// Returns true if the caller shall update the settings belonging to the scope.
pub(crate) fn update_timestamp(&mut self, scope: Param, new_timestamp: i64) -> Result<bool> {
pub(crate) fn update_timestamp(&mut self, scope: Param, new_timestamp: i64) -> bool {
let old_timestamp = self.get_i64(scope).unwrap_or_default();
if new_timestamp >= old_timestamp {
self.set_i64(scope, new_timestamp);
return Ok(true);
return true;
}
Ok(false)
false
}
}
@@ -96,18 +96,18 @@ mod tests {
let mut params = Params::new();
let ts = time();
assert!(params.update_timestamp(Param::LastSubject, ts)?);
assert!(params.update_timestamp(Param::LastSubject, ts)?); // same timestamp -> update
assert!(params.update_timestamp(Param::LastSubject, ts + 10)?);
assert!(!params.update_timestamp(Param::LastSubject, ts)?); // `ts` is now too old
assert!(!params.update_timestamp(Param::LastSubject, 0)?);
assert!(params.update_timestamp(Param::LastSubject, ts));
assert!(params.update_timestamp(Param::LastSubject, ts)); // same timestamp -> update
assert!(params.update_timestamp(Param::LastSubject, ts + 10));
assert!(!params.update_timestamp(Param::LastSubject, ts)); // `ts` is now too old
assert!(!params.update_timestamp(Param::LastSubject, 0));
assert_eq!(params.get_i64(Param::LastSubject).unwrap(), ts + 10);
assert!(params.update_timestamp(Param::GroupNameTimestamp, 0)?); // stay unset -> update ...
assert!(params.update_timestamp(Param::GroupNameTimestamp, 0)?); // ... also on multiple calls
assert!(params.update_timestamp(Param::GroupNameTimestamp, 0)); // stay unset -> update ...
assert!(params.update_timestamp(Param::GroupNameTimestamp, 0)); // ... also on multiple calls
assert_eq!(params.get_i64(Param::GroupNameTimestamp).unwrap(), 0);
assert!(!params.update_timestamp(Param::AvatarTimestamp, -1)?);
assert!(!params.update_timestamp(Param::AvatarTimestamp, -1));
assert_eq!(params.get_i64(Param::AvatarTimestamp), None);
Ok(())
+3 -3
View File
@@ -350,7 +350,7 @@ impl Context {
if let Some(ref document) = status_update_item.document
&& instance
.param
.update_timestamp(Param::WebxdcDocumentTimestamp, timestamp)?
.update_timestamp(Param::WebxdcDocumentTimestamp, timestamp)
{
instance.param.set(Param::WebxdcDocument, document);
param_changed = true;
@@ -359,10 +359,10 @@ impl Context {
if let Some(ref summary) = status_update_item.summary
&& instance
.param
.update_timestamp(Param::WebxdcSummaryTimestamp, timestamp)?
.update_timestamp(Param::WebxdcSummaryTimestamp, timestamp)
{
let summary = sanitize_bidi_characters(summary);
instance.param.set(Param::WebxdcSummary, summary.clone());
instance.param.set(Param::WebxdcSummary, summary);
param_changed = true;
}
+12 -14
View File
@@ -1201,30 +1201,28 @@ async fn test_get_webxdc_blob_with_subdirs() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_webxdc_manifest() -> Result<()> {
let result = parse_webxdc_manifest(r#"key = syntax error"#.as_bytes());
let result = parse_webxdc_manifest(br#"key = syntax error"#);
assert!(result.is_err());
let manifest = parse_webxdc_manifest(r#"no_name = "no name, no icon""#.as_bytes())?;
let manifest = parse_webxdc_manifest(br#"no_name = "no name, no icon""#)?;
assert_eq!(manifest.name, None);
let manifest = parse_webxdc_manifest(r#"name = "name, no icon""#.as_bytes())?;
let manifest = parse_webxdc_manifest(br#"name = "name, no icon""#)?;
assert_eq!(manifest.name, Some("name, no icon".to_string()));
let manifest = parse_webxdc_manifest(
r#"name = "foo"
icon = "bar""#
.as_bytes(),
br#"name = "foo"
icon = "bar""#,
)?;
assert_eq!(manifest.name, Some("foo".to_string()));
let manifest = parse_webxdc_manifest(
r#"name = "foz"
br#"name = "foz"
icon = "baz"
add_item = "that should be just ignored"
[section]
sth_for_the = "future""#
.as_bytes(),
sth_for_the = "future""#,
)?;
assert_eq!(manifest.name, Some("foz".to_string()));
Ok(())
@@ -1232,13 +1230,13 @@ sth_for_the = "future""#
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_webxdc_manifest_min_api() -> Result<()> {
let manifest = parse_webxdc_manifest(r#"min_api = 3"#.as_bytes())?;
let manifest = parse_webxdc_manifest(br#"min_api = 3"#)?;
assert_eq!(manifest.min_api, Some(3));
let result = parse_webxdc_manifest(r#"min_api = "1""#.as_bytes());
let result = parse_webxdc_manifest(br#"min_api = "1""#);
assert!(result.is_err());
let result = parse_webxdc_manifest(r#"min_api = 1.2"#.as_bytes());
let result = parse_webxdc_manifest(br#"min_api = 1.2"#);
assert!(result.is_err());
Ok(())
@@ -1246,10 +1244,10 @@ async fn test_parse_webxdc_manifest_min_api() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_webxdc_manifest_source_code_url() -> Result<()> {
let result = parse_webxdc_manifest(r#"source_code_url = 3"#.as_bytes());
let result = parse_webxdc_manifest(br#"source_code_url = 3"#);
assert!(result.is_err());
let manifest = parse_webxdc_manifest(r#"source_code_url = "https://foo.bar""#.as_bytes())?;
let manifest = parse_webxdc_manifest(br#"source_code_url = "https://foo.bar""#)?;
assert_eq!(
manifest.source_code_url,
Some("https://foo.bar".to_string())