Compare commits

...

27 Commits

Author SHA1 Message Date
link2xt
cdfe436124 chore(release): prepare for 2.27.0 2025-11-16 06:34:11 +00:00
link2xt
e8823fcf35 test: test background_fetch() and stop_background_fetch() 2025-11-16 05:59:20 +00:00
link2xt
0136cfaf6a test: add pytest fixture for account manager 2025-11-16 05:59:20 +00:00
link2xt
07069c348b api(deltachat-rpc-client): add APIs for background fetch 2025-11-16 05:59:20 +00:00
Hocuri
26f6b85ff9 feat!: Withdraw broadcast invites. Add Qr::WithdrawJoinBroadcast and Qr::ReviveJoinBroadcast QR code types. (#7439)
Add the ability to withdraw broadcast invite codes

After merging:
- [x] Create issues in iOS, Desktop and UT repositories
2025-11-15 19:27:04 +01:00
Hocuri
10b6dd1f11 test(rpc-client): test_qr_securejoin_broadcast: Wait for incoming message before getting chatlist (#7442)
Fix flaky test by calling `get_broadcast()` after the message events
were received.

Alternative to https://github.com/chatmail/core/pull/7437
2025-11-15 18:49:16 +01:00
B. Petersen
cae642b024 fix: send webm as file, it is not supported by all UI 2025-11-15 14:55:40 +01:00
B. Petersen
54a2e94525 fix: deprecate deletion timer string for '1 Minute'
the minimum timestamp in UI is 5 minutes
and the old string is about to be removed from translations.
the 'seconds' fallback is good enough, however
2025-11-15 14:41:54 +01:00
link2xt
9d4ad00fc0 build(nix): exclude CONTRIBUTING.md from the source files 2025-11-15 10:56:08 +00:00
Nico de Haen
102b72aadd fix: escape connectivity html 2025-11-14 22:50:15 +00:00
link2xt
1c4d2dd78e api: add APIs to stop background fetch
New APIs are JSON-RPC method stop_background_fetch(),
Rust method Accounts.stop_background_fetch()
and C method dc_accounts_stop_background_fetch().

These APIs allow to cancel background fetch early
even before the initially set timeout,
for example on Android when the system calls
`Service.onTimeout()` for a `dataSync` foreground service.
2025-11-14 22:48:19 +00:00
link2xt
cd50c263e8 api!(jsonrpc): rename accounts_background_fetch() into background_fetch()
There is no JSON-RPC method to run background_fetch() for a single account,
so no need to have a qualifier saying that it is for all accounts.
2025-11-14 22:48:19 +00:00
iequidoo
1dbcd7f1f4 test: HP-Outer headers are added to messages with standard Header Protection (#7130) 2025-11-14 19:45:32 -03:00
iequidoo
c6894f56b2 feat: Add Config::StdHeaderProtectionComposing (enables composing as defined in RFC 9788) (#7130)
And enable it by default as the standard Header Protection is backward-compatible.

Also this tests extra IMF header removal when a message has standard Header Protection since now we
can send such messages.
2025-11-14 19:45:32 -03:00
iequidoo
e2ae6ae013 feat: mimeparser: Omit Legacy Display Elements (#7130)
Omit Legacy Display Elements from "text/plain" and "text/html" (implement 4.5.3.{2,3} of
https://www.rfc-editor.org/rfc/rfc9788 "Header Protection for Cryptographically Protected Email").
2025-11-14 19:45:32 -03:00
iequidoo
966ea28f83 feat: Ignore unprotected headers if Content-Type has "hp" parameter (#7130)
This is a part of implementation of https://www.rfc-editor.org/rfc/rfc9788 "Header Protection for
Cryptographically Protected Email".
2025-11-14 19:45:32 -03:00
link2xt
6611a9fa02 fix: always set bcc_self on backup import/export
Regardless of whether chatmail relay is used or not,
bcc_self should be enabled when second device is added.
It should also be enabled again even if the user
has turned it off manually.
2025-11-14 20:00:34 +00:00
Simon Laux
dc4ea1865a fix: set get_max_smtp_rcpt_to for chatmail to the actual limit of 1000 instead of unlimited. (#7432)
adb brought this up in an internal discussion.
With the recent introduction of channels it becomes easier to hit the
limit
and it becomes impossible to send messages to a channel with more than
1000 members, this pr fixes that.

---------

Co-authored-by: Hocuri <hocuri@gmx.de>
2025-11-13 18:36:35 +00:00
link2xt
4b1dff601d refactor: use wait_for_incoming_msg() in more tests 2025-11-13 00:25:16 +00:00
link2xt
a66808e25a api(rpc-client): add Account.wait_for_msg() 2025-11-13 00:25:16 +00:00
link2xt
7b54954401 test: port folder-related CFFI tests to JSON-RPC
Created new test_folders.py

Moved existing JSON-RPC tests:
- test_reactions_for_a_reordering_move
- test_delete_deltachat_folder

Ported tests:
- test_move_works_on_self_sent
- test_moved_markseen
- test_markseen_message_and_mdn
- test_mvbox_thread_and_trash (renamed to test_mvbox_and_trash)
- test_scan_folders
- test_move_works
- test_move_avoids_loop
- test_immediate_autodelete
- test_trash_multiple_messages

The change also contains fixes for direct_imap fixture
needed to use IMAP IDLE in JSON-RPC tests.
2025-11-12 08:07:40 +00:00
link2xt
d39ed9d0f1 test: fix flaky test_send_receive_locations 2025-11-12 05:50:00 +00:00
iequidoo
c499dabbe1 feat: Add Contact::get_or_gen_color. Use it in CFFI and JSON-RPC to avoid gray self-color (#7374)
`Contact::get_color()` returns gray if own keypair doesn't exist yet and we don't want any UIs
displaying it. Keypair generation can't be done in `get_color()` or `get_by_id_optional()` to avoid
breaking Core tests on key import. Also this makes the API clearer, pure getters shouldn't modify
any visible state.
2025-11-12 00:30:42 -03:00
Hocuri
e70307af1f feat: Tweak initial info-message for unencrypted chats (#7427)
Fix https://github.com/chatmail/core/issues/7404
2025-11-11 19:28:28 +01:00
link2xt
69a3a31554 chore(release): prepare for 2.26.0 2025-11-11 17:30:19 +00:00
link2xt
1cb0a25e16 fix: do not ignore I/O errors in BlobObject::store_from_base64 2025-11-08 20:06:45 +00:00
iequidoo
fdea6c8af3 feat: Error toast for "Not creating securejoin QR for old broadcast" 2025-11-08 16:23:15 -03:00
54 changed files with 1561 additions and 756 deletions

View File

@@ -1,5 +1,87 @@
# Changelog
## [2.27.0] - 2025-11-16
### API-Changes
- Add APIs to stop background fetch.
- [**breaking**]: rename JSON-RPC method accounts_background_fetch() into background_fetch()
- rpc-client: Add APIs for background fetch.
- rpc-client: Add Account.wait_for_msg().
- Deprecate deletion timer string for '1 Minute'.
### Features / Changes
- Implement RFC 9788 (Header Protection for Cryptographically Protected Email) ([#7130](https://github.com/chatmail/core/pull/7130)).
- Tweak initial info-message for unencrypted chats ([#7427](https://github.com/chatmail/core/pull/7427)).
- Add Contact::get_or_gen_color. Use it in CFFI and JSON-RPC to avoid gray self-color ([#7374](https://github.com/chatmail/core/pull/7374)).
- [**breaking**] Withdraw broadcast invites. Add Qr::WithdrawJoinBroadcast and Qr::ReviveJoinBroadcast QR code types. ([#7439](https://github.com/chatmail/core/pull/7439)).
### Fixes
- Set `get_max_smtp_rcpt_to` for chatmail to the actual limit of 1000 instead of unlimited. ([#7432](https://github.com/chatmail/core/pull/7432)).
- Always set bcc_self on backup import/export.
- Escape connectivity HTML.
- Send webm as file, it is not supported by all UI.
### Build system
- nix: Exclude CONTRIBUTING.md from the source files.
### Refactor
- Use wait_for_incoming_msg() in more tests.
### Tests
- Fix flaky test_send_receive_locations.
- Port folder-related CFFI tests to JSON-RPC.
- HP-Outer headers are added to messages with standard Header Protection ([#7130](https://github.com/chatmail/core/pull/7130)).
- rpc-client: Test_qr_securejoin_broadcast: Wait for incoming message before getting chatlist ([#7442](https://github.com/chatmail/core/pull/7442)).
- Add pytest fixture for account manager.
- Test background_fetch() and stop_background_fetch().
## [2.26.0] - 2025-11-11
### API-Changes
- [**breaking**] JSON-RPC: `chat_type` now contains a variant of a string enum/union. Affected places: `FullChat.chat_type`, `BasicChat.chat_type`, `ChatListItemFetchResult::ChatListItem.chat_type`, `Event:: SecurejoinInviterProgress.chat_type` and `MessageSearchResult.chat_type` ([#7285](https://github.com/chatmail/core/pull/7285))
### Features / Changes
- Error toast for "Not creating securejoin QR for old broadcast".
### Fixes
- `is_encrypted()` should be true for Saved Messages chat so messages there are editable.
- Do not return an error from `receive_imf` if we fail to add a member because we are not in chat.
- Do not add QR inviter to groups immediately.
- Do not ignore I/O errors in `BlobObject::store_from_base64`.
### Miscellaneous Tasks
- Rustfmt.
### Refactor
- imap: Move resync request from Context to Imap.
- Replace imap:: calls in migration 73 with SQL queries.
- Remove unused imports.
### Documentation
- Readme: update language binding section to avoid usage of cffi in new projects ([#7380](https://github.com/chatmail/core/pull/7380)).
- Fix Context::set_stock_translation reference.
### Tests
- Test editing saved messages.
- Remove ThreadPoolExecutor from test_wait_next_messages.
- Move test_two_group_securejoins from receive_imf to securejoin module.
- At the end of securejoin Bob has two members in a group chat.
- Bob has 0 members in the chat until securejoin finishes.
- Do not add QR inviter to groups right after scanning the code.
## [2.25.0] - 2025-11-05
### Features / Changes
@@ -7101,3 +7183,5 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[2.23.0]: https://github.com/chatmail/core/compare/v2.22.0..v2.23.0
[2.24.0]: https://github.com/chatmail/core/compare/v2.23.0..v2.24.0
[2.25.0]: https://github.com/chatmail/core/compare/v2.24.0..v2.25.0
[2.26.0]: https://github.com/chatmail/core/compare/v2.25.0..v2.26.0
[2.27.0]: https://github.com/chatmail/core/compare/v2.26.0..v2.27.0

10
Cargo.lock generated
View File

@@ -1304,7 +1304,7 @@ dependencies = [
[[package]]
name = "deltachat"
version = "2.25.0"
version = "2.27.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -1413,7 +1413,7 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc"
version = "2.25.0"
version = "2.27.0"
dependencies = [
"anyhow",
"async-channel 2.5.0",
@@ -1435,7 +1435,7 @@ dependencies = [
[[package]]
name = "deltachat-repl"
version = "2.25.0"
version = "2.27.0"
dependencies = [
"anyhow",
"deltachat",
@@ -1451,7 +1451,7 @@ dependencies = [
[[package]]
name = "deltachat-rpc-server"
version = "2.25.0"
version = "2.27.0"
dependencies = [
"anyhow",
"deltachat",
@@ -1480,7 +1480,7 @@ dependencies = [
[[package]]
name = "deltachat_ffi"
version = "2.25.0"
version = "2.27.0"
dependencies = [
"anyhow",
"deltachat",

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.25.0"
version = "2.27.0"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.85"

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat_ffi"
version = "2.25.0"
version = "2.27.0"
description = "Deltachat FFI"
edition = "2018"
readme = "README.md"

View File

@@ -2578,8 +2578,10 @@ void dc_stop_ongoing_process (dc_context_t* context);
#define DC_QR_ERROR 400 // text1=error string
#define DC_QR_WITHDRAW_VERIFYCONTACT 500
#define DC_QR_WITHDRAW_VERIFYGROUP 502 // text1=groupname
#define DC_QR_WITHDRAW_JOINBROADCAST 504 // text1=broadcast name
#define DC_QR_REVIVE_VERIFYCONTACT 510
#define DC_QR_REVIVE_VERIFYGROUP 512 // text1=groupname
#define DC_QR_REVIVE_JOINBROADCAST 514 // text1=broadcast name
#define DC_QR_LOGIN 520 // text1=email_address
/**
@@ -3296,12 +3298,30 @@ void dc_accounts_maybe_network_lost (dc_accounts_t* accounts);
* without forgetting to create notifications caused by timing race conditions.
*
* @memberof dc_accounts_t
* @param accounts The account manager as created by dc_accounts_new().
* @param timeout The timeout in seconds
* @return Return 1 if DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE was emitted and 0 otherwise.
*/
int dc_accounts_background_fetch (dc_accounts_t* accounts, uint64_t timeout);
/**
* Stop ongoing background fetch.
*
* Calling this function allows to stop dc_accounts_background_fetch() early.
* dc_accounts_background_fetch() will then return immediately
* and emit DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE unless
* if it has failed and returned 0.
*
* If there is no ongoing dc_accounts_background_fetch() call,
* calling this function does nothing.
*
* @memberof dc_accounts_t
* @param accounts The account manager as created by dc_accounts_new().
*/
void dc_accounts_stop_background_fetch (dc_accounts_t *accounts);
/**
* Sets device token for Apple Push Notification service.
* Returns immediately.
@@ -7518,14 +7538,13 @@ void dc_event_unref(dc_event_t* event);
/// "You set message deletion timer to 1 minute."
///
/// Used in status messages.
/// @deprecated 2025-11-14, this string is no longer needed
#define DC_STR_EPHEMERAL_TIMER_1_MINUTE_BY_YOU 142
/// "Message deletion timer is set to 1 minute by %1$s."
///
/// `%1$s` will be replaced by name and address of the contact.
///
/// Used in status messages.
/// @deprecated 2025-11-14, this string is no longer needed
#define DC_STR_EPHEMERAL_TIMER_1_MINUTE_BY_OTHER 143
/// "You set message deletion timer to 1 hour."
@@ -7749,6 +7768,11 @@ void dc_event_unref(dc_event_t* event);
/// Description in connectivity view when proxy is enabled.
#define DC_STR_PROXY_ENABLED_DESCRIPTION 221
/// "Messages in this chat use classic email and are not encrypted."
///
/// Used as the first info messages in newly created classic email threads.
#define DC_STR_CHAT_UNENCRYPTED_EXPLANATON 230
/**
* @}
*/

View File

@@ -4240,7 +4240,17 @@ pub unsafe extern "C" fn dc_contact_get_color(contact: *mut dc_contact_t) -> u32
return 0;
}
let ffi_contact = &*contact;
ffi_contact.contact.get_color()
let ctx = &*ffi_contact.context;
block_on(async move {
ffi_contact
.contact
// We don't want any UIs displaying gray self-color.
.get_or_gen_color(ctx)
.await
.context("Contact::get_color()")
.log_err(ctx)
.unwrap_or(0)
})
}
#[no_mangle]
@@ -5017,6 +5027,17 @@ pub unsafe extern "C" fn dc_accounts_background_fetch(
1
}
#[no_mangle]
pub unsafe extern "C" fn dc_accounts_stop_background_fetch(accounts: *mut dc_accounts_t) {
if accounts.is_null() {
eprintln!("ignoring careless call to dc_accounts_stop_background_fetch()");
return;
}
let accounts = &*accounts;
block_on(accounts.read()).stop_background_fetch();
}
#[no_mangle]
pub unsafe extern "C" fn dc_accounts_set_push_device_token(
accounts: *mut dc_accounts_t,

View File

@@ -58,8 +58,10 @@ impl Lot {
Qr::Text { text } => Some(Cow::Borrowed(text)),
Qr::WithdrawVerifyContact { .. } => None,
Qr::WithdrawVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
Qr::WithdrawJoinBroadcast { name, .. } => Some(Cow::Borrowed(name)),
Qr::ReviveVerifyContact { .. } => None,
Qr::ReviveVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
Qr::ReviveJoinBroadcast { name, .. } => Some(Cow::Borrowed(name)),
Qr::Login { address, .. } => Some(Cow::Borrowed(address)),
},
Self::Error(err) => Some(Cow::Borrowed(err)),
@@ -112,8 +114,10 @@ impl Lot {
Qr::Text { .. } => LotState::QrText,
Qr::WithdrawVerifyContact { .. } => LotState::QrWithdrawVerifyContact,
Qr::WithdrawVerifyGroup { .. } => LotState::QrWithdrawVerifyGroup,
Qr::WithdrawJoinBroadcast { .. } => LotState::QrWithdrawJoinBroadcast,
Qr::ReviveVerifyContact { .. } => LotState::QrReviveVerifyContact,
Qr::ReviveVerifyGroup { .. } => LotState::QrReviveVerifyGroup,
Qr::ReviveJoinBroadcast { .. } => LotState::QrReviveJoinBroadcast,
Qr::Login { .. } => LotState::QrLogin,
},
Self::Error(_err) => LotState::QrError,
@@ -138,9 +142,11 @@ impl Lot {
Qr::Url { .. } => Default::default(),
Qr::Text { .. } => Default::default(),
Qr::WithdrawVerifyContact { contact_id, .. } => contact_id.to_u32(),
Qr::WithdrawVerifyGroup { .. } => Default::default(),
Qr::WithdrawVerifyGroup { .. } | Qr::WithdrawJoinBroadcast { .. } => {
Default::default()
}
Qr::ReviveVerifyContact { contact_id, .. } => contact_id.to_u32(),
Qr::ReviveVerifyGroup { .. } => Default::default(),
Qr::ReviveVerifyGroup { .. } | Qr::ReviveJoinBroadcast { .. } => Default::default(),
Qr::Login { .. } => Default::default(),
},
Self::Error(_) => Default::default(),
@@ -207,11 +213,15 @@ pub enum LotState {
/// text1=groupname
QrWithdrawVerifyGroup = 502,
/// text1=broadcast channel name
QrWithdrawJoinBroadcast = 504,
QrReviveVerifyContact = 510,
/// text1=groupname
QrReviveVerifyGroup = 512,
/// text1=groupname
QrReviveJoinBroadcast = 514,
/// text1=email_address
QrLogin = 520,

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-jsonrpc"
version = "2.25.0"
version = "2.27.0"
description = "DeltaChat JSON-RPC API"
edition = "2021"
license = "MPL-2.0"

View File

@@ -273,7 +273,7 @@ impl CommandApi {
/// The `AccountsBackgroundFetchDone` event is emitted at the end even in case of timeout.
/// Process all events until you get this one and you can safely return to the background
/// without forgetting to create notifications caused by timing race conditions.
async fn accounts_background_fetch(&self, timeout_in_seconds: f64) -> Result<()> {
async fn background_fetch(&self, timeout_in_seconds: f64) -> Result<()> {
let future = {
let lock = self.accounts.read().await;
lock.background_fetch(std::time::Duration::from_secs_f64(timeout_in_seconds))
@@ -283,6 +283,11 @@ impl CommandApi {
Ok(())
}
async fn stop_background_fetch(&self) -> Result<()> {
self.accounts.read().await.stop_background_fetch();
Ok(())
}
// ---------------------------------------------
// Methods that work on individual accounts
// ---------------------------------------------

View File

@@ -32,7 +32,10 @@ impl Account {
let addr = ctx.get_config(Config::Addr).await?;
let profile_image = ctx.get_config(Config::Selfavatar).await?;
let color = color_int_to_hex_string(
Contact::get_by_id(ctx, ContactId::SELF).await?.get_color(),
Contact::get_by_id(ctx, ContactId::SELF)
.await?
.get_or_gen_color(ctx)
.await?,
);
let private_tag = ctx.get_config(Config::PrivateTag).await?;
Ok(Account::Configured {

View File

@@ -157,6 +157,21 @@ pub enum QrObject {
/// Authentication code.
authcode: String,
},
/// Ask the user if they want to withdraw their own broadcast channel invite QR code.
WithdrawJoinBroadcast {
/// Broadcast name.
name: String,
/// ID, uniquely identifying this chat. Called grpid for historic reasons.
grpid: String,
/// Contact ID. Always `ContactId::SELF`.
contact_id: u32,
/// Fingerprint of the contact key as scanned from the QR code.
fingerprint: String,
/// Invite number.
invitenumber: String,
/// Authentication code.
authcode: String,
},
/// Ask the user if they want to revive their own QR code.
ReviveVerifyContact {
/// Contact ID.
@@ -183,6 +198,21 @@ pub enum QrObject {
/// Authentication code.
authcode: String,
},
/// Ask the user if they want to revive their own broadcast channel invite QR code.
ReviveJoinBroadcast {
/// Broadcast name.
name: String,
/// Globally unique chat ID. Called grpid for historic reasons.
grpid: String,
/// Contact ID. Always `ContactId::SELF`.
contact_id: u32,
/// Fingerprint of the contact key as scanned from the QR code.
fingerprint: String,
/// Invite number.
invitenumber: String,
/// Authentication code.
authcode: String,
},
/// `dclogin:` scheme parameters.
///
/// Ask the user if they want to login with the email address.
@@ -306,6 +336,25 @@ impl From<Qr> for QrObject {
authcode,
}
}
Qr::WithdrawJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.to_string();
QrObject::WithdrawJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
}
}
Qr::ReviveVerifyContact {
contact_id,
fingerprint,
@@ -340,6 +389,25 @@ impl From<Qr> for QrObject {
authcode,
}
}
Qr::ReviveJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.to_string();
QrObject::ReviveJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
}
}
Qr::Login { address, .. } => QrObject::Login { address },
}
}

View File

@@ -54,5 +54,5 @@
},
"type": "module",
"types": "dist/deltachat.d.ts",
"version": "2.25.0"
"version": "2.27.0"
}

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-repl"
version = "2.25.0"
version = "2.27.0"
license = "MPL-2.0"
edition = "2021"
repository = "https://github.com/chatmail/core"

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "2.25.0"
version = "2.27.0"
description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [
"Development Status :: 5 - Production/Stable",

View File

@@ -417,12 +417,21 @@ class Account:
"""Wait for messages noticed event and return it."""
return self.wait_for_event(EventType.MSGS_NOTICED)
def wait_for_msg(self, event_type) -> Message:
"""Wait for an event about the message.
Consumes all events before the matching event.
Returns a message corresponding to the msg_id field of the event.
"""
event = self.wait_for_event(event_type)
return self.get_message_by_id(event.msg_id)
def wait_for_incoming_msg(self):
"""Wait for incoming message and return it.
Consumes all events before the next incoming message event.
"""
return self.get_message_by_id(self.wait_for_incoming_msg_event().msg_id)
return self.wait_for_msg(EventType.INCOMING_MSG)
def wait_for_securejoin_inviter_success(self):
"""Wait until SecureJoin process finishes successfully on the inviter side."""

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from ._utils import AttrDict
from ._utils import AttrDict, futuremethod
from .account import Account
if TYPE_CHECKING:
@@ -39,6 +39,15 @@ class DeltaChat:
"""Stop the I/O of all accounts."""
self.rpc.stop_io_for_all_accounts()
@futuremethod
def background_fetch(self, timeout_in_seconds: int) -> None:
"""Run background fetch for all accounts."""
yield self.rpc.background_fetch.future(timeout_in_seconds)
def stop_background_fetch(self) -> None:
"""Stop ongoing background fetch."""
self.rpc.stop_background_fetch()
def maybe_network(self) -> None:
"""Indicate that the network conditions might have changed."""
self.rpc.maybe_network()

View File

@@ -135,9 +135,15 @@ def rpc(tmp_path) -> AsyncGenerator:
@pytest.fixture
def acfactory(rpc) -> AsyncGenerator:
def dc(rpc) -> DeltaChat:
"""Return account manager."""
return DeltaChat(rpc)
@pytest.fixture
def acfactory(dc) -> AsyncGenerator:
"""Return account factory fixture."""
return ACFactory(DeltaChat(rpc))
return ACFactory(dc)
@pytest.fixture

View File

@@ -85,11 +85,11 @@ class DirectImap:
def get_all_messages(self) -> list[MailMessage]:
assert not self._idling
return list(self.conn.fetch())
return list(self.conn.fetch(mark_seen=False))
def get_unread_messages(self) -> list[str]:
assert not self._idling
return [msg.uid for msg in self.conn.fetch(AND(seen=False))]
return [msg.uid for msg in self.conn.fetch(AND(seen=False), mark_seen=False)]
def mark_all_read(self):
messages = self.get_unread_messages()
@@ -173,7 +173,6 @@ class DirectImap:
class IdleManager:
def __init__(self, direct_imap) -> None:
self.direct_imap = direct_imap
self.log = direct_imap.account.log
# fetch latest messages before starting idle so that it only
# returns messages that arrive anew
self.direct_imap.conn.fetch("1:*")
@@ -181,14 +180,11 @@ class IdleManager:
def check(self, timeout=None) -> list[bytes]:
"""(blocking) wait for next idle message from server."""
self.log("imap-direct: calling idle_check")
res = self.direct_imap.conn.idle.poll(timeout=timeout)
self.log(f"imap-direct: idle_check returned {res!r}")
return res
return self.direct_imap.conn.idle.poll(timeout=timeout)
def wait_for_new_message(self, timeout=None) -> bytes:
def wait_for_new_message(self) -> bytes:
while True:
for item in self.check(timeout=timeout):
for item in self.check():
if b"EXISTS" in item or b"RECENT" in item:
return item
@@ -196,10 +192,8 @@ class IdleManager:
"""Return first message with SEEN flag from a running idle-stream."""
while True:
for item in self.check(timeout=timeout):
if FETCH in item:
self.log(str(item))
if FLAGS in item and rb"\Seen" in item:
return int(item.split(b" ")[1])
if FETCH in item and FLAGS in item and rb"\Seen" in item:
return int(item.split(b" ")[1])
def done(self):
"""send idle-done to server if we are currently in idle mode."""

View File

@@ -0,0 +1,538 @@
import logging
import re
import time
import pytest
from imap_tools import AND, U
from deltachat_rpc_client import Contact, EventType, Message
def test_move_works(acfactory):
ac1, ac2 = acfactory.get_online_accounts(2)
ac2.set_config("mvbox_move", "1")
ac2.bring_online()
chat = ac1.create_chat(ac2)
chat.send_text("message1")
# Message is moved to the movebox
ac2.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
# Message is downloaded
msg = ac2.wait_for_incoming_msg().get_snapshot()
assert msg.text == "message1"
def test_move_avoids_loop(acfactory, direct_imap):
"""Test that the message is only moved from INBOX to DeltaChat.
This is to avoid busy loop if moved message reappears in the Inbox
or some scanned folder later.
For example, this happens on servers that alias `INBOX.DeltaChat` to `DeltaChat` folder,
so the message moved to `DeltaChat` appears as a new message in the `INBOX.DeltaChat` folder.
We do not want to move this message from `INBOX.DeltaChat` to `DeltaChat` again.
"""
ac1, ac2 = acfactory.get_online_accounts(2)
ac2.set_config("mvbox_move", "1")
ac2.set_config("delete_server_after", "0")
ac2.bring_online()
# Create INBOX.DeltaChat folder and make sure
# it is detected by full folder scan.
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("INBOX.DeltaChat")
ac2.stop_io()
ac2.start_io()
while True:
event = ac2.wait_for_event()
# Wait until the end of folder scan.
if event.kind == EventType.INFO and "Found folders:" in event.msg:
break
ac1_chat = acfactory.get_accepted_chat(ac1, ac2)
ac1_chat.send_text("Message 1")
# Message is moved to the DeltaChat folder and downloaded.
ac2_msg1 = ac2.wait_for_incoming_msg().get_snapshot()
assert ac2_msg1.text == "Message 1"
# Move the message to the INBOX.DeltaChat again.
# We assume that test server uses "." as the delimiter.
ac2_direct_imap.select_folder("DeltaChat")
ac2_direct_imap.conn.move(["*"], "INBOX.DeltaChat")
ac1_chat.send_text("Message 2")
ac2_msg2 = ac2.wait_for_incoming_msg().get_snapshot()
assert ac2_msg2.text == "Message 2"
# Stop and start I/O to trigger folder scan.
ac2.stop_io()
ac2.start_io()
while True:
event = ac2.wait_for_event()
# Wait until the end of folder scan.
if event.kind == EventType.INFO and "Found folders:" in event.msg:
break
# Check that Message 1 is still in the INBOX.DeltaChat folder
# and Message 2 is in the DeltaChat folder.
ac2_direct_imap.select_folder("INBOX")
assert len(ac2_direct_imap.get_all_messages()) == 0
ac2_direct_imap.select_folder("DeltaChat")
assert len(ac2_direct_imap.get_all_messages()) == 1
ac2_direct_imap.select_folder("INBOX.DeltaChat")
assert len(ac2_direct_imap.get_all_messages()) == 1
def test_reactions_for_a_reordering_move(acfactory, direct_imap):
"""When a batch of messages is moved from Inbox to DeltaChat folder with a single MOVE command,
their UIDs may be reordered (e.g. Gmail is known for that) which led to that messages were
processed by receive_imf in the wrong order, and, particularly, reactions were processed before
messages they refer to and thus dropped.
"""
(ac1,) = acfactory.get_online_accounts(1)
addr, password = acfactory.get_credentials()
ac2 = acfactory.get_unconfigured_account()
ac2.add_or_update_transport({"addr": addr, "password": password})
ac2.set_config("mvbox_move", "1")
assert ac2.is_configured()
ac2.bring_online()
chat1 = acfactory.get_accepted_chat(ac1, ac2)
ac2.stop_io()
logging.info("sending message + reaction from ac1 to ac2")
msg1 = chat1.send_text("hi")
msg1.wait_until_delivered()
# It's is sad, but messages must differ in their INTERNALDATEs to be processed in the correct
# order by DC, and most (if not all) mail servers provide only seconds precision.
time.sleep(1.1)
react_str = "\N{THUMBS UP SIGN}"
msg1.send_reaction(react_str).wait_until_delivered()
logging.info("moving messages to ac2's DeltaChat folder in the reverse order")
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.connect()
for uid in sorted([m.uid for m in ac2_direct_imap.get_all_messages()], reverse=True):
ac2_direct_imap.conn.move(uid, "DeltaChat")
logging.info("receiving messages by ac2")
ac2.start_io()
msg2 = Message(ac2, ac2.wait_for_reactions_changed().msg_id)
assert msg2.get_snapshot().text == msg1.get_snapshot().text
reactions = msg2.get_reactions()
contacts = [Contact(ac2, int(i)) for i in reactions.reactions_by_contact]
assert len(contacts) == 1
assert contacts[0].get_snapshot().address == ac1.get_config("addr")
assert list(reactions.reactions_by_contact.values())[0] == [react_str]
def test_delete_deltachat_folder(acfactory, direct_imap):
"""Test that DeltaChat folder is recreated if user deletes it manually."""
ac1 = acfactory.new_configured_account()
ac1.set_config("mvbox_move", "1")
ac1.bring_online()
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.conn.folder.delete("DeltaChat")
assert "DeltaChat" not in ac1_direct_imap.list_folders()
# Wait until new folder is created and UIDVALIDITY is updated.
while True:
event = ac1.wait_for_event()
if event.kind == EventType.INFO and "uid/validity change folder DeltaChat" in event.msg:
break
ac2 = acfactory.get_online_account()
ac2.create_chat(ac1).send_text("hello")
msg = ac1.wait_for_incoming_msg().get_snapshot()
assert msg.text == "hello"
assert "DeltaChat" in ac1_direct_imap.list_folders()
def test_dont_show_emails(acfactory, direct_imap, log):
"""Most mailboxes have a "Drafts" folder where constantly new emails appear but we don't actually want to show them.
So: If it's outgoing AND there is no Received header, then ignore the email.
If the draft email is sent out and received later (i.e. it's in "Inbox"), it must be shown.
Also, test that unknown emails in the Spam folder are not shown."""
ac1 = acfactory.new_configured_account()
ac1.stop_io()
ac1.set_config("show_emails", "2")
ac1.create_contact("alice@example.org").create_chat()
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.create_folder("Drafts")
ac1_direct_imap.create_folder("Spam")
ac1_direct_imap.create_folder("Junk")
# Learn UID validity for all folders.
ac1.set_config("scan_all_folders_debounce_secs", "0")
ac1.start_io()
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
ac1.stop_io()
ac1_direct_imap.append(
"Drafts",
"""
From: ac1 <{}>
Subject: subj
To: alice@example.org
Message-ID: <aepiors@example.org>
Content-Type: text/plain; charset=utf-8
message in Drafts received later
""".format(
ac1.get_config("configured_addr"),
),
)
ac1_direct_imap.append(
"Spam",
"""
From: unknown.address@junk.org
Subject: subj
To: {}
Message-ID: <spam.message@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1_direct_imap.append(
"Spam",
"""
From: unknown.address@junk.org, unkwnown.add@junk.org
Subject: subj
To: {}
Message-ID: <spam.message2@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown & malformed message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1_direct_imap.append(
"Spam",
"""
From: delta<address: inbox@nhroy.com>
Subject: subj
To: {}
Message-ID: <spam.message99@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown & malformed message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1_direct_imap.append(
"Spam",
"""
From: alice@example.org
Subject: subj
To: {}
Message-ID: <spam.message3@junk.org>
Content-Type: text/plain; charset=utf-8
Actually interesting message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1_direct_imap.append(
"Junk",
"""
From: unknown.address@junk.org
Subject: subj
To: {}
Message-ID: <spam.message@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown message in Junk
""".format(
ac1.get_config("configured_addr"),
),
)
ac1.set_config("scan_all_folders_debounce_secs", "0")
log.section("All prepared, now let DC find the message")
ac1.start_io()
# Wait until each folder was scanned, this is necessary for this test to test what it should test:
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
fresh_msgs = list(ac1.get_fresh_messages())
msg = fresh_msgs[0].get_snapshot()
chat_msgs = msg.chat.get_messages()
assert len(chat_msgs) == 1
assert msg.text == "subj Actually interesting message in Spam"
assert not any("unknown.address" in c.get_full_snapshot().name for c in ac1.get_chatlist())
ac1_direct_imap.select_folder("Spam")
assert ac1_direct_imap.get_uid_by_message_id("spam.message@junk.org")
ac1.stop_io()
log.section("'Send out' the draft by moving it to Inbox, and wait for DC to display it this time")
ac1_direct_imap.select_folder("Drafts")
uid = ac1_direct_imap.get_uid_by_message_id("aepiors@example.org")
ac1_direct_imap.conn.move(uid, "Inbox")
ac1.start_io()
event = ac1.wait_for_event(EventType.MSGS_CHANGED)
msg2 = Message(ac1, event.msg_id).get_snapshot()
assert msg2.text == "subj message in Drafts received later"
assert len(msg.chat.get_messages()) == 2
def test_move_works_on_self_sent(acfactory):
ac1, ac2 = acfactory.get_online_accounts(2)
# Enable movebox and wait until it is created.
ac1.set_config("mvbox_move", "1")
ac1.set_config("bcc_self", "1")
ac1.bring_online()
chat = ac1.create_chat(ac2)
chat.send_text("message1")
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
chat.send_text("message2")
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
chat.send_text("message3")
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
def test_moved_markseen(acfactory, direct_imap):
"""Test that message already moved to DeltaChat folder is marked as seen."""
ac1, ac2 = acfactory.get_online_accounts(2)
ac2.set_config("mvbox_move", "1")
ac2.set_config("delete_server_after", "0")
ac2.set_config("sync_msgs", "0") # Do not send a sync message when accepting a contact request.
ac2.bring_online()
ac2.stop_io()
ac2_direct_imap = direct_imap(ac2)
with ac2_direct_imap.idle() as idle2:
ac1.create_chat(ac2).send_text("Hello!")
idle2.wait_for_new_message()
# Emulate moving of the message to DeltaChat folder by Sieve rule.
ac2_direct_imap.conn.move(["*"], "DeltaChat")
ac2_direct_imap.select_folder("DeltaChat")
assert len(list(ac2_direct_imap.conn.fetch("*", mark_seen=False))) == 1
with ac2_direct_imap.idle() as idle2:
ac2.start_io()
ev = ac2.wait_for_event(EventType.MSGS_CHANGED)
msg = ac2.get_message_by_id(ev.msg_id)
assert msg.get_snapshot().text == "Messages are end-to-end encrypted."
ev = ac2.wait_for_event(EventType.INCOMING_MSG)
msg = ac2.get_message_by_id(ev.msg_id)
chat = ac2.get_chat_by_id(ev.chat_id)
# Accept the contact request.
chat.accept()
msg.mark_seen()
idle2.wait_for_seen()
assert len(list(ac2_direct_imap.conn.fetch(AND(seen=True, uid=U(1, "*")), mark_seen=False))) == 1
@pytest.mark.parametrize("mvbox_move", [True, False])
def test_markseen_message_and_mdn(acfactory, direct_imap, mvbox_move):
ac1, ac2 = acfactory.get_online_accounts(2)
for ac in ac1, ac2:
ac.set_config("delete_server_after", "0")
if mvbox_move:
ac.set_config("mvbox_move", "1")
ac.bring_online()
# Do not send BCC to self, we only want to test MDN on ac1.
ac1.set_config("bcc_self", "0")
acfactory.get_accepted_chat(ac1, ac2).send_text("hi")
msg = ac2.wait_for_incoming_msg()
msg.mark_seen()
if mvbox_move:
rex = re.compile("Marked messages [0-9]+ in folder DeltaChat as seen.")
else:
rex = re.compile("Marked messages [0-9]+ in folder INBOX as seen.")
for ac in ac1, ac2:
while True:
event = ac.wait_for_event()
if event.kind == EventType.INFO and rex.search(event.msg):
break
folder = "mvbox" if mvbox_move else "inbox"
ac1_direct_imap = direct_imap(ac1)
ac2_direct_imap = direct_imap(ac2)
ac1_direct_imap.select_config_folder(folder)
ac2_direct_imap.select_config_folder(folder)
# Check that the mdn is marked as seen
assert len(list(ac1_direct_imap.conn.fetch(AND(seen=True), mark_seen=False))) == 1
# Check original message is marked as seen
assert len(list(ac2_direct_imap.conn.fetch(AND(seen=True), mark_seen=False))) == 1
def test_mvbox_and_trash(acfactory, direct_imap, log):
log.section("ac1: start with mvbox")
ac1 = acfactory.get_online_account()
ac1.set_config("mvbox_move", "1")
ac1.bring_online()
log.section("ac2: start without a mvbox")
ac2 = acfactory.get_online_account()
log.section("ac1: create trash")
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.create_folder("Trash")
ac1.set_config("scan_all_folders_debounce_secs", "0")
ac1.stop_io()
ac1.start_io()
log.section("ac1: send message and wait for ac2 to receive it")
acfactory.get_accepted_chat(ac1, ac2).send_text("message1")
assert ac2.wait_for_incoming_msg().get_snapshot().text == "message1"
assert ac1.get_config("configured_mvbox_folder") == "DeltaChat"
while ac1.get_config("configured_trash_folder") != "Trash":
ac1.wait_for_event(EventType.CONNECTIVITY_CHANGED)
@pytest.mark.parametrize(
("folder", "move", "expected_destination"),
[
(
"xyz",
False,
"xyz",
), # Test that emails aren't found in a random folder
(
"xyz",
True,
"xyz",
), # ...emails are found in a random folder and downloaded without moving
(
"Spam",
False,
"INBOX",
), # ...emails are moved from the spam folder to the Inbox
],
)
# Testrun.org does not support the CREATE-SPECIAL-USE capability, which means that we can't create a folder with
# the "\Junk" flag (see https://tools.ietf.org/html/rfc6154). So, we can't test spam folder detection by flag.
def test_scan_folders(acfactory, log, direct_imap, folder, move, expected_destination):
"""Delta Chat periodically scans all folders for new messages to make sure we don't miss any."""
variant = folder + "-" + str(move) + "-" + expected_destination
log.section("Testing variant " + variant)
ac1, ac2 = acfactory.get_online_accounts(2)
ac1.set_config("delete_server_after", "0")
if move:
ac1.set_config("mvbox_move", "1")
ac1.bring_online()
ac1.stop_io()
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.create_folder(folder)
# Wait until each folder was selected once and we are IDLEing:
ac1.start_io()
ac1.bring_online()
ac1.stop_io()
assert folder in ac1_direct_imap.list_folders()
log.section("Send a message from ac2 to ac1 and manually move it to `folder`")
ac1_direct_imap.select_config_folder("inbox")
with ac1_direct_imap.idle() as idle1:
acfactory.get_accepted_chat(ac2, ac1).send_text("hello")
idle1.wait_for_new_message()
ac1_direct_imap.conn.move(["*"], folder) # "*" means "biggest UID in mailbox"
log.section("start_io() and see if DeltaChat finds the message (" + variant + ")")
ac1.set_config("scan_all_folders_debounce_secs", "0")
ac1.start_io()
chat = ac1.create_chat(ac2)
n_msgs = 1 # "Messages are end-to-end encrypted."
if folder == "Spam":
msg = ac1.wait_for_incoming_msg().get_snapshot()
assert msg.text == "hello"
n_msgs += 1
else:
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
assert len(chat.get_messages()) == n_msgs
# The message has reached its destination.
ac1_direct_imap.select_folder(expected_destination)
assert len(ac1_direct_imap.get_all_messages()) == 1
if folder != expected_destination:
ac1_direct_imap.select_folder(folder)
assert len(ac1_direct_imap.get_all_messages()) == 0
def test_trash_multiple_messages(acfactory, direct_imap, log):
ac1, ac2 = acfactory.get_online_accounts(2)
ac2.stop_io()
# Create the Trash folder on IMAP server and configure deletion to it. There was a bug that if
# Trash wasn't configured initially, it can't be configured later, let's check this.
log.section("Creating trash folder")
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("Trash")
ac2.set_config("delete_server_after", "0")
ac2.set_config("sync_msgs", "0")
ac2.set_config("delete_to_trash", "1")
log.section("Check that Trash can be configured initially as well")
ac3 = ac2.clone()
ac3.bring_online()
assert ac3.get_config("configured_trash_folder")
ac3.stop_io()
ac2.start_io()
chat12 = acfactory.get_accepted_chat(ac1, ac2)
log.section("ac1: sending 3 messages")
texts = ["first", "second", "third"]
for text in texts:
chat12.send_text(text)
log.section("ac2: waiting for all messages on the other side")
to_delete = []
for text in texts:
msg = ac2.wait_for_incoming_msg().get_snapshot()
assert msg.text in texts
if text != "second":
to_delete.append(msg)
# ac2 has received some messages, this is impossible w/o the trash folder configured, let's
# check the configuration.
assert ac2.get_config("configured_trash_folder") == "Trash"
log.section("ac2: deleting all messages except second")
assert len(to_delete) == len(texts) - 1
ac2.delete_messages(to_delete)
log.section("ac2: test that only one message is left")
while 1:
ac2.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
ac2_direct_imap.select_config_folder("inbox")
nr_msgs = len(ac2_direct_imap.get_all_messages())
assert nr_msgs > 0
if nr_msgs == 1:
break

View File

@@ -84,7 +84,7 @@ def test_realtime_sequentially(acfactory, path_to_webxdc):
# share a webxdc app between ac1 and ac2
ac1_webxdc_msg = acfactory.send_message(from_account=ac1, to_account=ac2, text="play", file=path_to_webxdc)
ac2_webxdc_msg = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id)
ac2_webxdc_msg = ac2.wait_for_incoming_msg()
snapshot = ac2_webxdc_msg.get_snapshot()
assert snapshot.text == "play"
@@ -94,7 +94,7 @@ def test_realtime_sequentially(acfactory, path_to_webxdc):
acfactory.send_message(from_account=ac1, to_account=ac2, text="ping1")
log("waiting for incoming message on ac2")
snapshot = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac2.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "ping1"
log("sending ac2 -> ac1 realtime advertisement and additional message")
@@ -102,7 +102,7 @@ def test_realtime_sequentially(acfactory, path_to_webxdc):
acfactory.send_message(from_account=ac2, to_account=ac1, text="ping2")
log("waiting for incoming message on ac1")
snapshot = ac1.get_message_by_id(ac1.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac1.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "ping2"
log("sending realtime data ac1 -> ac2")

View File

@@ -4,6 +4,41 @@ from deltachat_rpc_client import EventType
from deltachat_rpc_client.const import MessageState
def test_bcc_self_delete_server_after_defaults(acfactory):
"""Test default values for bcc_self and delete_server_after."""
ac = acfactory.get_online_account()
# Initially after getting online
# the setting bcc_self is set to 0 because there is only one device
# and delete_server_after is "1", meaning immediate deletion.
assert ac.get_config("bcc_self") == "0"
assert ac.get_config("delete_server_after") == "1"
# Setup a second device.
ac_clone = ac.clone()
ac_clone.bring_online()
# Second device setup
# enables bcc_self and changes default delete_server_after.
assert ac.get_config("bcc_self") == "1"
assert ac.get_config("delete_server_after") == "0"
assert ac_clone.get_config("bcc_self") == "1"
assert ac_clone.get_config("delete_server_after") == "0"
# Manually disabling bcc_self
# also restores the default for delete_server_after.
ac.set_config("bcc_self", "0")
assert ac.get_config("bcc_self") == "0"
assert ac.get_config("delete_server_after") == "1"
# Cloning the account again enables bcc_self
# even though it was manually disabled.
ac_clone = ac.clone()
assert ac.get_config("bcc_self") == "1"
assert ac.get_config("delete_server_after") == "0"
def test_one_account_send_bcc_setting(acfactory, log, direct_imap):
ac1, ac2 = acfactory.get_online_accounts(2)
ac1_clone = ac1.clone()

View File

@@ -86,7 +86,7 @@ def test_qr_securejoin(acfactory):
alice_contact_bob_snapshot = alice_contact_bob.get_snapshot()
assert alice_contact_bob_snapshot.is_verified
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Member Me added by {}.".format(alice.get_config("addr"))
# Test that Bob verified Alice's profile.
@@ -140,15 +140,15 @@ def test_qr_securejoin_broadcast(acfactory, all_devices_online):
return chat
def wait_for_broadcast_messages(ac):
snapshot1 = ac.wait_for_incoming_msg().get_snapshot()
assert snapshot1.text == "You joined the channel."
snapshot2 = ac.wait_for_incoming_msg().get_snapshot()
assert snapshot2.text == "Hello everyone!"
chat = get_broadcast(ac)
snapshot = ac.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "You joined the channel."
assert snapshot.chat_id == chat.id
snapshot = ac.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello everyone!"
assert snapshot.chat_id == chat.id
assert snapshot1.chat_id == chat.id
assert snapshot2.chat_id == chat.id
def check_account(ac, contact, inviter_side, please_wait_info_msg=False):
# Check that the chat partner is verified.
@@ -255,7 +255,7 @@ def test_qr_securejoin_contact_request(acfactory) -> None:
alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.send_text("Hello!")
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello!"
bob_chat_alice = snapshot.chat
assert bob_chat_alice.get_basic_snapshot().is_contact_request
@@ -299,8 +299,7 @@ def test_qr_readreceipt(acfactory) -> None:
logging.info("Bob and Charlie receive a group")
bob_msg_id = bob.wait_for_incoming_msg_event().msg_id
bob_message = bob.get_message_by_id(bob_msg_id)
bob_message = bob.wait_for_incoming_msg()
bob_snapshot = bob_message.get_snapshot()
assert bob_snapshot.text == "Hello"
@@ -311,8 +310,7 @@ def test_qr_readreceipt(acfactory) -> None:
bob_out_message = bob_snapshot.chat.send_message(text="Hi from Bob!")
charlie_msg_id = charlie.wait_for_incoming_msg_event().msg_id
charlie_message = charlie.get_message_by_id(charlie_msg_id)
charlie_message = charlie.wait_for_incoming_msg()
charlie_snapshot = charlie_message.get_snapshot()
assert charlie_snapshot.text == "Hi from Bob!"
@@ -387,7 +385,7 @@ def test_verified_group_member_added_recovery(acfactory) -> None:
ac3_contact_ac2 = ac3.create_contact(ac2)
ac3_chat.remove_contact(ac3_contact_ac2_old)
snapshot = ac1.get_message_by_id(ac1.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac1.wait_for_incoming_msg().get_snapshot()
assert "removed" in snapshot.text
ac3_chat.add_contact(ac3_contact_ac2)
@@ -400,18 +398,17 @@ def test_verified_group_member_added_recovery(acfactory) -> None:
logging.info("ac2 got event message: %s", snapshot.text)
assert "added" in snapshot.text
snapshot = ac1.get_message_by_id(ac1.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac1.wait_for_incoming_msg().get_snapshot()
assert "added" in snapshot.text
chat = Chat(ac2, chat_id)
chat.send_text("Works again!")
msg_id = ac3.wait_for_incoming_msg_event().msg_id
message = ac3.get_message_by_id(msg_id)
message = ac3.wait_for_incoming_msg()
snapshot = message.get_snapshot()
assert snapshot.text == "Works again!"
snapshot = ac1.get_message_by_id(ac1.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac1.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Works again!"
ac1_contact_ac2 = ac1.create_contact(ac2)
@@ -447,7 +444,7 @@ def test_qr_join_chat_with_pending_bobstate_issue4894(acfactory):
# ensure ac1 can write and ac2 receives messages in verified chat
ch1.send_text("ac1 says hello")
while 1:
snapshot = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac2.wait_for_incoming_msg().get_snapshot()
if snapshot.text == "ac1 says hello":
break
@@ -468,7 +465,7 @@ def test_qr_join_chat_with_pending_bobstate_issue4894(acfactory):
# ensure ac2 receives message in VG
vg.send_text("hello")
while 1:
msg = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot()
msg = ac2.wait_for_incoming_msg().get_snapshot()
if msg.text == "hello":
break
@@ -505,7 +502,7 @@ def test_qr_new_group_unblocked(acfactory):
ac2.wait_for_incoming_msg_event()
ac1_new_chat.send_text("Hello!")
ac2_msg = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot()
ac2_msg = ac2.wait_for_incoming_msg().get_snapshot()
assert ac2_msg.text == "Hello!"
assert ac2_msg.chat.get_basic_snapshot().is_contact_request
@@ -530,7 +527,7 @@ def test_aeap_flow_verified(acfactory):
logging.info("receiving first message")
ac2.wait_for_incoming_msg_event() # member added message
msg_in_1 = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot()
msg_in_1 = ac2.wait_for_incoming_msg().get_snapshot()
assert msg_in_1.text == msg_out.text
logging.info("changing email account")
@@ -544,7 +541,7 @@ def test_aeap_flow_verified(acfactory):
msg_out = chat.send_text("changed address").get_snapshot()
logging.info("receiving second message")
msg_in_2 = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id)
msg_in_2 = ac2.wait_for_incoming_msg()
msg_in_2_snapshot = msg_in_2.get_snapshot()
assert msg_in_2_snapshot.text == msg_out.text
assert msg_in_2_snapshot.chat.id == msg_in_1.chat.id
@@ -576,7 +573,7 @@ def test_gossip_verification(acfactory) -> None:
bob_group_chat.add_contact(bob_contact_carol)
bob_group_chat.send_message(text="Hello Autocrypt group")
snapshot = carol.get_message_by_id(carol.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = carol.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello Autocrypt group"
assert snapshot.show_padlock
@@ -592,7 +589,7 @@ def test_gossip_verification(acfactory) -> None:
bob_group_chat.add_contact(bob_contact_carol)
bob_group_chat.send_message(text="Hello Securejoin group")
snapshot = carol.get_message_by_id(carol.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = carol.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello Securejoin group"
assert snapshot.show_padlock
@@ -620,7 +617,7 @@ def test_securejoin_after_contact_resetup(acfactory) -> None:
ac1.wait_for_securejoin_joiner_success()
# ac1 waits for member added message and creates a QR code.
snapshot = ac1.get_message_by_id(ac1.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac1.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Member Me added by {}.".format(ac3.get_config("addr"))
ac1_qr_code = snapshot.chat.get_qr_code()
@@ -657,7 +654,7 @@ def test_securejoin_after_contact_resetup(acfactory) -> None:
# Wait for member added.
logging.info("ac2 waits for member added message")
snapshot = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac2.wait_for_incoming_msg().get_snapshot()
assert snapshot.is_info
ac2_chat = snapshot.chat
assert len(ac2_chat.get_contacts()) == 3
@@ -679,7 +676,7 @@ def test_withdraw_securejoin_qr(acfactory):
alice.clear_all_events()
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Member Me added by {}.".format(alice.get_config("addr"))
bob_chat.leave()

View File

@@ -352,9 +352,7 @@ def test_receive_imf_failure(acfactory) -> None:
# The failed message doesn't break the IMAP loop.
bob.set_config("fail_on_receiving_full_msg", "0")
alice_chat_bob.send_text("Hello again!")
event = bob.wait_for_incoming_msg_event()
msg_id = event.msg_id
message = bob.get_message_by_id(msg_id)
message = bob.wait_for_incoming_msg()
snapshot = message.get_snapshot()
assert snapshot.text == "Hello again!"
assert snapshot.download_state == DownloadState.DONE
@@ -423,10 +421,7 @@ def test_is_bot(acfactory) -> None:
alice.set_config("bot", "1")
alice_chat_bob.send_text("Hello!")
event = bob.wait_for_incoming_msg_event()
message = bob.get_message_by_id(event.msg_id)
snapshot = message.get_snapshot()
assert snapshot.chat_id == event.chat_id
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello!"
assert snapshot.is_bot
@@ -518,7 +513,7 @@ def test_import_export_keys(acfactory, tmp_path) -> None:
alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.send_text("Hello Bob!")
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello Bob!"
# Alice resetups account, but keeps the key.
@@ -530,7 +525,7 @@ def test_import_export_keys(acfactory, tmp_path) -> None:
snapshot.chat.accept()
snapshot.chat.send_text("Hello Alice!")
snapshot = alice.get_message_by_id(alice.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = alice.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello Alice!"
assert snapshot.show_padlock
@@ -575,18 +570,13 @@ def test_mdn_doesnt_break_autocrypt(acfactory) -> None:
# Alice sends a message to Bob.
alice_chat_bob.send_text("Hello Bob!")
event = bob.wait_for_incoming_msg_event()
msg_id = event.msg_id
message = bob.get_message_by_id(msg_id)
snapshot = message.get_snapshot()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
# Bob sends a message to Alice.
bob_chat_alice = snapshot.chat
bob_chat_alice.accept()
bob_chat_alice.send_text("Hello Alice!")
event = alice.wait_for_incoming_msg_event()
msg_id = event.msg_id
message = alice.get_message_by_id(msg_id)
message = alice.wait_for_incoming_msg()
snapshot = message.get_snapshot()
assert snapshot.show_padlock
@@ -596,10 +586,7 @@ def test_mdn_doesnt_break_autocrypt(acfactory) -> None:
# Bob sends a message to Alice, it should also be encrypted.
bob_chat_alice.send_text("Hi Alice!")
event = alice.wait_for_incoming_msg_event()
msg_id = event.msg_id
message = alice.get_message_by_id(msg_id)
snapshot = message.get_snapshot()
snapshot = alice.wait_for_incoming_msg().get_snapshot()
assert snapshot.show_padlock
@@ -657,50 +644,6 @@ def test_reaction_to_partially_fetched_msg(acfactory, tmp_path):
assert list(reactions.reactions_by_contact.values())[0] == [react_str]
def test_reactions_for_a_reordering_move(acfactory, direct_imap):
"""When a batch of messages is moved from Inbox to DeltaChat folder with a single MOVE command,
their UIDs may be reordered (e.g. Gmail is known for that) which led to that messages were
processed by receive_imf in the wrong order, and, particularly, reactions were processed before
messages they refer to and thus dropped.
"""
(ac1,) = acfactory.get_online_accounts(1)
addr, password = acfactory.get_credentials()
ac2 = acfactory.get_unconfigured_account()
ac2.add_or_update_transport({"addr": addr, "password": password})
ac2.set_config("mvbox_move", "1")
assert ac2.is_configured()
ac2.bring_online()
chat1 = acfactory.get_accepted_chat(ac1, ac2)
ac2.stop_io()
logging.info("sending message + reaction from ac1 to ac2")
msg1 = chat1.send_text("hi")
msg1.wait_until_delivered()
# It's is sad, but messages must differ in their INTERNALDATEs to be processed in the correct
# order by DC, and most (if not all) mail servers provide only seconds precision.
time.sleep(1.1)
react_str = "\N{THUMBS UP SIGN}"
msg1.send_reaction(react_str).wait_until_delivered()
logging.info("moving messages to ac2's DeltaChat folder in the reverse order")
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.connect()
for uid in sorted([m.uid for m in ac2_direct_imap.get_all_messages()], reverse=True):
ac2_direct_imap.conn.move(uid, "DeltaChat")
logging.info("receiving messages by ac2")
ac2.start_io()
msg2 = Message(ac2, ac2.wait_for_reactions_changed().msg_id)
assert msg2.get_snapshot().text == msg1.get_snapshot().text
reactions = msg2.get_reactions()
contacts = [Contact(ac2, int(i)) for i in reactions.reactions_by_contact]
assert len(contacts) == 1
assert contacts[0].get_snapshot().address == ac1.get_config("addr")
assert list(reactions.reactions_by_contact.values())[0] == [react_str]
@pytest.mark.parametrize("n_accounts", [3, 2])
def test_download_limit_chat_assignment(acfactory, tmp_path, n_accounts):
download_limit = 300000
@@ -712,7 +655,7 @@ def test_download_limit_chat_assignment(acfactory, tmp_path, n_accounts):
for account in others:
chat = account.create_chat(alice)
chat.send_text("Hello Alice!")
assert alice.get_message_by_id(alice.wait_for_incoming_msg_event().msg_id).get_snapshot().text == "Hello Alice!"
assert alice.wait_for_incoming_msg().get_snapshot().text == "Hello Alice!"
contact = alice.create_contact(account)
alice_group.add_contact(contact)
@@ -722,7 +665,7 @@ def test_download_limit_chat_assignment(acfactory, tmp_path, n_accounts):
bob.set_config("download_limit", str(download_limit))
alice_group.send_text("hi")
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "hi"
bob_group = snapshot.chat
@@ -732,7 +675,7 @@ def test_download_limit_chat_assignment(acfactory, tmp_path, n_accounts):
for i in range(10):
logging.info("Sending message %s", i)
alice_group.send_file(str(path))
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.download_state == DownloadState.AVAILABLE
if n_accounts > 2:
assert snapshot.chat == bob_group
@@ -759,8 +702,8 @@ def test_markseen_contact_request(acfactory):
alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.send_text("Hello Bob!")
message = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id)
message2 = bob2.get_message_by_id(bob2.wait_for_incoming_msg_event().msg_id)
message = bob.wait_for_incoming_msg()
message2 = bob2.wait_for_incoming_msg()
assert message2.get_snapshot().state == MessageState.IN_FRESH
message.mark_seen()
@@ -782,7 +725,7 @@ def test_read_receipt(acfactory):
msg = bob.wait_for_incoming_msg()
msg.mark_seen()
read_msg = alice.get_message_by_id(alice.wait_for_event(EventType.MSG_READ).msg_id)
read_msg = alice.wait_for_msg(EventType.MSG_READ)
read_receipts = read_msg.get_read_receipts()
assert len(read_receipts) == 1
assert read_receipts[0].contact_id == alice_contact_bob.id
@@ -888,30 +831,6 @@ def test_get_all_accounts_deadlock(rpc):
all_accounts()
def test_delete_deltachat_folder(acfactory, direct_imap):
"""Test that DeltaChat folder is recreated if user deletes it manually."""
ac1 = acfactory.new_configured_account()
ac1.set_config("mvbox_move", "1")
ac1.bring_online()
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.conn.folder.delete("DeltaChat")
assert "DeltaChat" not in ac1_direct_imap.list_folders()
# Wait until new folder is created and UIDVALIDITY is updated.
while True:
event = ac1.wait_for_event()
if event.kind == EventType.INFO and "uid/validity change folder DeltaChat" in event.msg:
break
ac2 = acfactory.get_online_account()
ac2.create_chat(ac1).send_text("hello")
msg = ac1.wait_for_incoming_msg().get_snapshot()
assert msg.text == "hello"
assert "DeltaChat" in ac1_direct_imap.list_folders()
@pytest.mark.parametrize("all_devices_online", [True, False])
def test_leave_broadcast(acfactory, all_devices_online):
alice, bob = acfactory.get_online_accounts(2)
@@ -1012,3 +931,74 @@ def test_leave_broadcast(acfactory, all_devices_online):
bob2.wait_for_event(EventType.CHAT_MODIFIED)
check_account(bob2, bob2.create_contact(alice), inviter_side=False)
def test_immediate_autodelete(acfactory, direct_imap, log):
ac1, ac2 = acfactory.get_online_accounts(2)
# "1" means delete immediately, while "0" means do not delete
ac2.set_config("delete_server_after", "1")
log.section("ac1: create chat with ac2")
chat1 = ac1.create_chat(ac2)
ac2.create_chat(ac1)
log.section("ac1: send message to ac2")
sent_msg = chat1.send_text("hello")
msg = ac2.wait_for_incoming_msg()
assert msg.get_snapshot().text == "hello"
log.section("ac2: wait for close/expunge on autodelete")
ac2.wait_for_event(EventType.IMAP_MESSAGE_DELETED)
while True:
event = ac2.wait_for_event()
if event.kind == EventType.INFO and "Close/expunge succeeded." in event.msg:
break
log.section("ac2: check that message was autodeleted on server")
ac2_direct_imap = direct_imap(ac2)
assert len(ac2_direct_imap.get_all_messages()) == 0
log.section("ac2: Mark deleted message as seen and check that read receipt arrives")
msg.mark_seen()
ev = ac1.wait_for_event(EventType.MSG_READ)
assert ev.chat_id == chat1.id
assert ev.msg_id == sent_msg.id
def test_background_fetch(acfactory, dc):
ac1, ac2 = acfactory.get_online_accounts(2)
ac1.stop_io()
ac1_chat = ac1.create_chat(ac2)
ac2_chat = ac2.create_chat(ac1)
ac2_chat.send_text("Hello!")
while True:
dc.background_fetch(300)
messages = ac1_chat.get_messages()
snapshot = messages[-1].get_snapshot()
if snapshot.text == "Hello!":
break
# Stopping background fetch immediately after starting
# does not result in any errors.
background_fetch_future = dc.background_fetch.future(300)
dc.stop_background_fetch()
background_fetch_future()
# Starting background fetch with zero timeout is ok,
# it should terminate immediately.
dc.background_fetch(0)
# Background fetch can still be used to send and receive messages.
ac2_chat.send_text("Hello again!")
while True:
dc.background_fetch(300)
messages = ac1_chat.get_messages()
snapshot = messages[-1].get_snapshot()
if snapshot.text == "Hello again!":
break

View File

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

View File

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

View File

@@ -34,7 +34,6 @@
./Cargo.lock
./Cargo.toml
./CMakeLists.txt
./CONTRIBUTING.md
./deltachat_derive
./deltachat-contact-tools
./deltachat-ffi

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat"
version = "2.25.0"
version = "2.27.0"
description = "Python bindings for the Delta Chat Core library using CFFI against the Rust-implemented libdeltachat"
readme = "README.rst"
requires-python = ">=3.8"

View File

@@ -5,7 +5,7 @@ import base64
from datetime import datetime, timezone
import pytest
from imap_tools import AND, U
from imap_tools import AND
import deltachat as dc
from deltachat import account_hookimpl, Message
@@ -269,112 +269,6 @@ def test_enable_mvbox_move(acfactory, lp):
assert ac2._evtracker.wait_next_incoming_message().text == "message1"
def test_mvbox_thread_and_trash(acfactory, lp):
lp.sec("ac1: start with mvbox thread")
ac1 = acfactory.new_online_configuring_account(mvbox_move=True)
lp.sec("ac2: start without a mvbox thread")
ac2 = acfactory.new_online_configuring_account(mvbox_move=False)
lp.sec("ac2 and ac1: waiting for configuration")
acfactory.bring_accounts_online()
lp.sec("ac1: create trash")
ac1.direct_imap.create_folder("Trash")
ac1.set_config("scan_all_folders_debounce_secs", "0")
ac1.stop_io()
ac1.start_io()
lp.sec("ac1: send message and wait for ac2 to receive it")
acfactory.get_accepted_chat(ac1, ac2).send_text("message1")
assert ac2._evtracker.wait_next_incoming_message().text == "message1"
assert ac1.get_config("configured_mvbox_folder") == "DeltaChat"
while ac1.get_config("configured_trash_folder") != "Trash":
ac1._evtracker.get_matching("DC_EVENT_CONNECTIVITY_CHANGED")
def test_move_works(acfactory):
ac1 = acfactory.new_online_configuring_account()
ac2 = acfactory.new_online_configuring_account(mvbox_move=True)
acfactory.bring_accounts_online()
chat = acfactory.get_accepted_chat(ac1, ac2)
chat.send_text("message1")
# Message is moved to the movebox
ac2._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_MOVED")
# Message is downloaded
ev = ac2._evtracker.get_matching("DC_EVENT_INCOMING_MSG")
assert ev.data2 > dc.const.DC_CHAT_ID_LAST_SPECIAL
def test_move_avoids_loop(acfactory):
"""Test that the message is only moved from INBOX to DeltaChat.
This is to avoid busy loop if moved message reappears in the Inbox
or some scanned folder later.
For example, this happens on servers that alias `INBOX.DeltaChat` to `DeltaChat` folder,
so the message moved to `DeltaChat` appears as a new message in the `INBOX.DeltaChat` folder.
We do not want to move this message from `INBOX.DeltaChat` to `DeltaChat` again.
"""
ac1 = acfactory.new_online_configuring_account()
ac2 = acfactory.new_online_configuring_account(mvbox_move=True)
acfactory.bring_accounts_online()
# Create INBOX.DeltaChat folder and make sure
# it is detected by full folder scan.
ac2.direct_imap.create_folder("INBOX.DeltaChat")
ac2.stop_io()
ac2.start_io()
ac2._evtracker.get_info_contains("Found folders:") # Wait until the end of folder scan.
ac1_chat = acfactory.get_accepted_chat(ac1, ac2)
ac1_chat.send_text("Message 1")
# Message is moved to the DeltaChat folder and downloaded.
ac2_msg1 = ac2._evtracker.wait_next_incoming_message()
assert ac2_msg1.text == "Message 1"
# Move the message to the INBOX.DeltaChat again.
# We assume that test server uses "." as the delimiter.
ac2.direct_imap.select_folder("DeltaChat")
ac2.direct_imap.conn.move(["*"], "INBOX.DeltaChat")
ac1_chat.send_text("Message 2")
ac2_msg2 = ac2._evtracker.wait_next_incoming_message()
assert ac2_msg2.text == "Message 2"
# Stop and start I/O to trigger folder scan.
ac2.stop_io()
ac2.start_io()
ac2._evtracker.get_info_contains("Found folders:") # Wait until the end of folder scan.
# Check that Message 1 is still in the INBOX.DeltaChat folder
# and Message 2 is in the DeltaChat folder.
ac2.direct_imap.select_folder("INBOX")
assert len(ac2.direct_imap.get_all_messages()) == 0
ac2.direct_imap.select_folder("DeltaChat")
assert len(ac2.direct_imap.get_all_messages()) == 1
ac2.direct_imap.select_folder("INBOX.DeltaChat")
assert len(ac2.direct_imap.get_all_messages()) == 1
def test_move_works_on_self_sent(acfactory):
ac1 = acfactory.new_online_configuring_account(mvbox_move=True)
ac2 = acfactory.new_online_configuring_account()
acfactory.bring_accounts_online()
ac1.set_config("bcc_self", "1")
chat = acfactory.get_accepted_chat(ac1, ac2)
chat.send_text("message1")
ac1._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_MOVED")
chat.send_text("message2")
ac1._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_MOVED")
chat.send_text("message3")
ac1._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_MOVED")
def test_move_sync_msgs(acfactory):
ac1 = acfactory.new_online_configuring_account(bcc_self=True, sync_msgs=True, fix_is_chatmail=True)
acfactory.bring_accounts_online()
@@ -607,39 +501,6 @@ def test_send_and_receive_message_markseen(acfactory, lp):
pass # mark_seen_messages() has generated events before it returns
def test_moved_markseen(acfactory):
"""Test that message already moved to DeltaChat folder is marked as seen."""
ac1 = acfactory.new_online_configuring_account()
ac2 = acfactory.new_online_configuring_account(mvbox_move=True)
acfactory.bring_accounts_online()
ac2.stop_io()
with ac2.direct_imap.idle() as idle2:
ac1.create_chat(ac2).send_text("Hello!")
idle2.wait_for_new_message()
# Emulate moving of the message to DeltaChat folder by Sieve rule.
ac2.direct_imap.conn.move(["*"], "DeltaChat")
ac2.direct_imap.select_folder("DeltaChat")
with ac2.direct_imap.idle() as idle2:
ac2.start_io()
ev = ac2._evtracker.get_matching("DC_EVENT_INCOMING_MSG|DC_EVENT_MSGS_CHANGED")
msg = ac2.get_message_by_id(ev.data2)
assert msg.text == "Messages are end-to-end encrypted."
ev = ac2._evtracker.get_matching("DC_EVENT_INCOMING_MSG|DC_EVENT_MSGS_CHANGED")
msg = ac2.get_message_by_id(ev.data2)
# Accept the contact request.
msg.chat.accept()
ac2.mark_seen_messages([msg])
uid = idle2.wait_for_seen()
assert len(list(ac2.direct_imap.conn.fetch(AND(seen=True, uid=U(uid, "*"))))) == 1
def test_message_override_sender_name(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
ac1.set_config("displayname", "ac1-default-displayname")
@@ -674,36 +535,6 @@ def test_message_override_sender_name(acfactory, lp):
assert not msg2.override_sender_name
@pytest.mark.parametrize("mvbox_move", [True, False])
def test_markseen_message_and_mdn(acfactory, mvbox_move):
# Please only change this test if you are very sure that it will still catch the issues it catches now.
# We had so many problems with markseen, if in doubt, rather create another test, it can't harm.
ac1 = acfactory.new_online_configuring_account(mvbox_move=mvbox_move)
ac2 = acfactory.new_online_configuring_account(mvbox_move=mvbox_move)
acfactory.bring_accounts_online()
# Do not send BCC to self, we only want to test MDN on ac1.
ac1.set_config("bcc_self", "0")
acfactory.get_accepted_chat(ac1, ac2).send_text("hi")
msg = ac2._evtracker.wait_next_incoming_message()
ac2.mark_seen_messages([msg])
folder = "mvbox" if mvbox_move else "inbox"
for ac in [ac1, ac2]:
if mvbox_move:
ac._evtracker.get_info_contains("Marked messages [0-9]+ in folder DeltaChat as seen.")
else:
ac._evtracker.get_info_contains("Marked messages [0-9]+ in folder INBOX as seen.")
ac1.direct_imap.select_config_folder(folder)
ac2.direct_imap.select_config_folder(folder)
# Check that the mdn is marked as seen
assert len(list(ac1.direct_imap.conn.fetch(AND(seen=True)))) == 1
# Check original message is marked as seen
assert len(list(ac2.direct_imap.conn.fetch(AND(seen=True)))) == 1
def test_reply_privately(acfactory):
ac1, ac2 = acfactory.get_online_accounts(2)
@@ -853,140 +684,6 @@ def test_no_draft_if_cant_send(acfactory):
assert device_chat.get_draft() is None
def test_dont_show_emails(acfactory, lp):
"""Most mailboxes have a "Drafts" folder where constantly new emails appear but we don't actually want to show them.
So: If it's outgoing AND there is no Received header, then ignore the email.
If the draft email is sent out and received later (i.e. it's in "Inbox"), it must be shown.
Also, test that unknown emails in the Spam folder are not shown."""
ac1 = acfactory.new_online_configuring_account()
ac1.set_config("show_emails", "2")
ac1.create_contact("alice@example.org").create_chat()
acfactory.wait_configured(ac1)
ac1.direct_imap.create_folder("Drafts")
ac1.direct_imap.create_folder("Spam")
ac1.direct_imap.create_folder("Junk")
acfactory.bring_accounts_online()
ac1.stop_io()
ac1.direct_imap.append(
"Drafts",
"""
From: ac1 <{}>
Subject: subj
To: alice@example.org
Message-ID: <aepiors@example.org>
Content-Type: text/plain; charset=utf-8
message in Drafts received later
""".format(
ac1.get_config("configured_addr"),
),
)
ac1.direct_imap.append(
"Spam",
"""
From: unknown.address@junk.org
Subject: subj
To: {}
Message-ID: <spam.message@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1.direct_imap.append(
"Spam",
"""
From: unknown.address@junk.org, unkwnown.add@junk.org
Subject: subj
To: {}
Message-ID: <spam.message2@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown & malformed message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1.direct_imap.append(
"Spam",
"""
From: delta<address: inbox@nhroy.com>
Subject: subj
To: {}
Message-ID: <spam.message99@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown & malformed message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1.direct_imap.append(
"Spam",
"""
From: alice@example.org
Subject: subj
To: {}
Message-ID: <spam.message3@junk.org>
Content-Type: text/plain; charset=utf-8
Actually interesting message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1.direct_imap.append(
"Junk",
"""
From: unknown.address@junk.org
Subject: subj
To: {}
Message-ID: <spam.message@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown message in Junk
""".format(
ac1.get_config("configured_addr"),
),
)
ac1.set_config("scan_all_folders_debounce_secs", "0")
lp.sec("All prepared, now let DC find the message")
ac1.start_io()
# Wait until each folder was scanned, this is necessary for this test to test what it should test:
ac1._evtracker.wait_idle_inbox_ready()
fresh_msgs = list(ac1.get_fresh_messages())
msg = fresh_msgs[0]
chat_msgs = msg.chat.get_messages()
assert len(chat_msgs) == 1
assert any(msg.text == "subj Actually interesting message in Spam" for msg in chat_msgs)
assert not any("unknown.address" in c.get_name() for c in ac1.get_chats())
ac1.direct_imap.select_folder("Spam")
assert ac1.direct_imap.get_uid_by_message_id("spam.message@junk.org")
ac1.stop_io()
lp.sec("'Send out' the draft by moving it to Inbox, and wait for DC to display it this time")
ac1.direct_imap.select_folder("Drafts")
uid = ac1.direct_imap.get_uid_by_message_id("aepiors@example.org")
ac1.direct_imap.conn.move(uid, "Inbox")
ac1.start_io()
msg2 = ac1._evtracker.wait_next_messages_changed()
assert msg2.text == "subj message in Drafts received later"
assert len(msg.chat.get_messages()) == 2
def test_bot(acfactory, lp):
"""Test that bot messages can be identified as such"""
ac1, ac2 = acfactory.get_online_accounts(2)
@@ -1509,9 +1206,15 @@ def test_send_receive_locations(acfactory, lp):
assert locations[0].latitude == 2.0
assert locations[0].longitude == 3.0
assert locations[0].accuracy == 0.5
assert locations[0].timestamp > now
assert locations[0].marker is None
# Make sure the timestamp is not in the past.
# Note that location timestamp has only 1 second precision,
# while `now` has a fractional part, so we have to truncate it
# first, otherwise `now` may appear to be in the future
# even though it is the same second.
assert int(locations[0].timestamp.timestamp()) >= int(now.timestamp())
contact = ac2.create_contact(ac1)
locations2 = chat2.get_locations(contact=contact)
assert len(locations2) == 1
@@ -1522,38 +1225,6 @@ def test_send_receive_locations(acfactory, lp):
assert not locations3
def test_immediate_autodelete(acfactory, lp):
ac1 = acfactory.new_online_configuring_account()
ac2 = acfactory.new_online_configuring_account()
acfactory.bring_accounts_online()
# "1" means delete immediately, while "0" means do not delete
ac2.set_config("delete_server_after", "1")
lp.sec("ac1: create chat with ac2")
chat1 = ac1.create_chat(ac2)
ac2.create_chat(ac1)
lp.sec("ac1: send message to ac2")
sent_msg = chat1.send_text("hello")
msg = ac2._evtracker.wait_next_incoming_message()
assert msg.text == "hello"
lp.sec("ac2: wait for close/expunge on autodelete")
ac2._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_DELETED")
ac2._evtracker.get_info_contains("Close/expunge succeeded.")
lp.sec("ac2: check that message was autodeleted on server")
assert len(ac2.direct_imap.get_all_messages()) == 0
lp.sec("ac2: Mark deleted message as seen and check that read receipt arrives")
msg.mark_seen()
ev = ac1._evtracker.get_matching("DC_EVENT_MSG_READ")
assert ev.data1 == chat1.id
assert ev.data2 == sent_msg.id
def test_delete_multiple_messages(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
chat12 = acfactory.get_accepted_chat(ac1, ac2)
@@ -1586,55 +1257,6 @@ def test_delete_multiple_messages(acfactory, lp):
break
def test_trash_multiple_messages(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
ac2.stop_io()
# Create the Trash folder on IMAP server and configure deletion to it. There was a bug that if
# Trash wasn't configured initially, it can't be configured later, let's check this.
lp.sec("Creating trash folder")
ac2.direct_imap.create_folder("Trash")
ac2.set_config("delete_to_trash", "1")
lp.sec("Check that Trash can be configured initially as well")
ac3 = acfactory.new_online_configuring_account(cloned_from=ac2)
acfactory.bring_accounts_online()
assert ac3.get_config("configured_trash_folder")
ac3.stop_io()
ac2.start_io()
chat12 = acfactory.get_accepted_chat(ac1, ac2)
lp.sec("ac1: sending 3 messages")
texts = ["first", "second", "third"]
for text in texts:
chat12.send_text(text)
lp.sec("ac2: waiting for all messages on the other side")
to_delete = []
for text in texts:
msg = ac2._evtracker.wait_next_incoming_message()
assert msg.text in texts
if text != "second":
to_delete.append(msg)
# ac2 has received some messages, this is impossible w/o the trash folder configured, let's
# check the configuration.
assert ac2.get_config("configured_trash_folder") == "Trash"
lp.sec("ac2: deleting all messages except second")
assert len(to_delete) == len(texts) - 1
ac2.delete_messages(to_delete)
lp.sec("ac2: test that only one message is left")
while 1:
ac2._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_MOVED")
ac2.direct_imap.select_config_folder("inbox")
nr_msgs = len(ac2.direct_imap.get_all_messages())
assert nr_msgs > 0
if nr_msgs == 1:
break
def test_configure_error_msgs_wrong_pw(acfactory):
(ac1,) = acfactory.get_online_accounts(1)
@@ -1758,71 +1380,6 @@ def test_group_quote(acfactory, lp):
assert received_reply.quote.id == out_msg.id
@pytest.mark.parametrize(
("folder", "move", "expected_destination"),
[
(
"xyz",
False,
"xyz",
), # Test that emails aren't found in a random folder
(
"xyz",
True,
"xyz",
), # ...emails are found in a random folder and downloaded without moving
(
"Spam",
False,
"INBOX",
), # ...emails are moved from the spam folder to the Inbox
],
)
# Testrun.org does not support the CREATE-SPECIAL-USE capability, which means that we can't create a folder with
# the "\Junk" flag (see https://tools.ietf.org/html/rfc6154). So, we can't test spam folder detection by flag.
def test_scan_folders(acfactory, lp, folder, move, expected_destination):
"""Delta Chat periodically scans all folders for new messages to make sure we don't miss any."""
variant = folder + "-" + str(move) + "-" + expected_destination
lp.sec("Testing variant " + variant)
ac1 = acfactory.new_online_configuring_account(mvbox_move=move)
ac2 = acfactory.new_online_configuring_account()
acfactory.wait_configured(ac1)
ac1.direct_imap.create_folder(folder)
# Wait until each folder was selected once and we are IDLEing:
acfactory.bring_accounts_online()
ac1.stop_io()
assert folder in ac1.direct_imap.list_folders()
lp.sec("Send a message to from ac2 to ac1 and manually move it to `folder`")
ac1.direct_imap.select_config_folder("inbox")
with ac1.direct_imap.idle() as idle1:
acfactory.get_accepted_chat(ac2, ac1).send_text("hello")
idle1.wait_for_new_message()
ac1.direct_imap.conn.move(["*"], folder) # "*" means "biggest UID in mailbox"
lp.sec("start_io() and see if DeltaChat finds the message (" + variant + ")")
ac1.set_config("scan_all_folders_debounce_secs", "0")
ac1.start_io()
chat = ac1.create_chat(ac2)
n_msgs = 1 # "Messages are end-to-end encrypted."
if folder == "Spam":
msg = ac1._evtracker.wait_next_incoming_message()
assert msg.text == "hello"
n_msgs += 1
else:
ac1._evtracker.wait_idle_inbox_ready()
assert len(chat.get_messages()) == n_msgs
# The message has reached its destination.
ac1.direct_imap.select_folder(expected_destination)
assert len(ac1.direct_imap.get_all_messages()) == 1
if folder != expected_destination:
ac1.direct_imap.select_folder(folder)
assert len(ac1.direct_imap.get_all_messages()) == 0
def test_archived_muted_chat(acfactory, lp):
"""If an archived and muted chat receives a new message, DC_EVENT_MSGS_CHANGED for
DC_CHAT_ID_ARCHIVED_LINK must be generated if the chat had only seen messages previously.

View File

@@ -1 +1 @@
2025-11-05
2025-11-16

View File

@@ -3,8 +3,12 @@
use std::collections::{BTreeMap, BTreeSet};
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context as _, Result, bail, ensure};
use async_channel::{self, Receiver, Sender};
use futures::FutureExt as _;
use futures_lite::FutureExt as _;
use serde::{Deserialize, Serialize};
use tokio::fs;
use tokio::io::AsyncWriteExt;
@@ -41,6 +45,13 @@ pub struct Accounts {
/// Push notification subscriber shared between accounts.
push_subscriber: PushSubscriber,
/// Channel sender to cancel ongoing background_fetch().
///
/// If background_fetch() is not running, this is `None`.
/// New background_fetch() should not be started if this
/// contains `Some`.
background_fetch_interrupt_sender: Arc<parking_lot::Mutex<Option<Sender<()>>>>,
}
impl Accounts {
@@ -96,6 +107,7 @@ impl Accounts {
events,
stockstrings,
push_subscriber,
background_fetch_interrupt_sender: Default::default(),
})
}
@@ -352,6 +364,11 @@ impl Accounts {
///
/// This is an auxiliary function and not part of public API.
/// Use [Accounts::background_fetch] instead.
///
/// This function is cancellation-safe.
/// It is intended to be cancellable,
/// either because of the timeout or because background
/// fetch was explicitly cancelled.
async fn background_fetch_no_timeout(accounts: Vec<Context>, events: Events) {
let n_accounts = accounts.len();
events.emit(Event {
@@ -378,14 +395,33 @@ impl Accounts {
}
/// Auxiliary function for [Accounts::background_fetch].
///
/// Runs `background_fetch` until it finishes
/// or until the timeout.
///
/// Produces `AccountsBackgroundFetchDone` event in every case
/// and clears [`Self::background_fetch_interrupt_sender`]
/// so a new background fetch can be started.
///
/// This function is not cancellation-safe.
/// Cancelling it before it returns may result
/// in not being able to run any new background fetch
/// if interrupt sender was not cleared.
async fn background_fetch_with_timeout(
accounts: Vec<Context>,
events: Events,
timeout: std::time::Duration,
interrupt_sender: Arc<parking_lot::Mutex<Option<Sender<()>>>>,
interrupt_receiver: Option<Receiver<()>>,
) {
let Some(interrupt_receiver) = interrupt_receiver else {
// Nothing to do if we got no interrupt receiver.
return;
};
if let Err(_err) = tokio::time::timeout(
timeout,
Self::background_fetch_no_timeout(accounts, events.clone()),
Self::background_fetch_no_timeout(accounts, events.clone())
.race(interrupt_receiver.recv().map(|_| ())),
)
.await
{
@@ -398,10 +434,16 @@ impl Accounts {
id: 0,
typ: EventType::AccountsBackgroundFetchDone,
});
(*interrupt_sender.lock()) = None;
}
/// Performs a background fetch for all accounts in parallel with a timeout.
///
/// Ongoing background fetch can also be cancelled manually
/// by calling `stop_background_fetch()`, in which case it will
/// return immediately even before the timeout expiration
/// or finishing fetching.
///
/// The `AccountsBackgroundFetchDone` event is emitted at the end,
/// process all events until you get this one and you can safely return to the background
/// without forgetting to create notifications caused by timing race conditions.
@@ -414,7 +456,39 @@ impl Accounts {
) -> impl Future<Output = ()> + use<> {
let accounts: Vec<Context> = self.accounts.values().cloned().collect();
let events = self.events.clone();
Self::background_fetch_with_timeout(accounts, events, timeout)
let (sender, receiver) = async_channel::bounded(1);
let receiver = {
let mut lock = self.background_fetch_interrupt_sender.lock();
if (*lock).is_some() {
// Another background_fetch() is already running,
// return immeidately.
None
} else {
*lock = Some(sender);
Some(receiver)
}
};
Self::background_fetch_with_timeout(
accounts,
events,
timeout,
self.background_fetch_interrupt_sender.clone(),
receiver,
)
}
/// Interrupts ongoing background_fetch() call,
/// making it return early.
///
/// This method allows to cancel background_fetch() early,
/// e.g. on Android, when `Service.onTimeout` is called.
///
/// If there is no ongoing background_fetch(), does nothing.
pub fn stop_background_fetch(&self) {
let mut lock = self.background_fetch_interrupt_sender.lock();
if let Some(sender) = lock.take() {
sender.try_send(()).ok();
}
}
/// Emits a single event.

View File

@@ -234,8 +234,13 @@ impl<'a> BlobObject<'a> {
/// 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> {
let buf = base64::engine::general_purpose::STANDARD.decode(data)?;
///
/// Returns an error if there is an I/O problem,
/// but in case of a failure to decode base64 returns `Ok(None)`.
pub(crate) fn store_from_base64(context: &Context, data: &str) -> Result<Option<String>> {
let Ok(buf) = base64::engine::general_purpose::STANDARD.decode(data) else {
return Ok(None);
};
let name = if let Ok(format) = image::guess_format(&buf) {
if let Some(ext) = format.extensions_str().first() {
format!("file.{ext}")
@@ -246,7 +251,7 @@ impl<'a> BlobObject<'a> {
String::new()
};
let blob = BlobObject::create_and_deduplicate_from_bytes(context, &buf, &name)?;
Ok(blob.as_name().to_string())
Ok(Some(blob.as_name().to_string()))
}
/// Recode image to avatar size.

View File

@@ -3463,7 +3463,13 @@ pub(crate) async fn create_group_ex(
if !context.get_config_bool(Config::Bot).await?
&& !context.get_config_bool(Config::SkipStartMessages).await?
{
let text = stock_str::new_group_send_first_message(context).await;
let text = if !grpid.is_empty() {
// Add "Others will only see this group after you sent a first message." message.
stock_str::new_group_send_first_message(context).await
} else {
// Add "Messages in this chat use classic email and are not encrypted." message.
stock_str::chat_unencrypted_explanation(context).await
};
add_info_msg(context, chat_id, &text, create_smeared_timestamp(context)).await?;
}
if let (true, true) = (sync.into(), !grpid.is_empty()) {

View File

@@ -2632,12 +2632,6 @@ async fn test_can_send_group() -> Result<()> {
/// the recipients can't see the identity of their fellow recipients.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
fn contains(parsed: &MimeMessage, s: &str) -> bool {
assert_eq!(parsed.decrypting_failed, false);
let decoded_str = std::str::from_utf8(&parsed.decoded_data).unwrap();
decoded_str.contains(s)
}
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
@@ -2669,8 +2663,8 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
);
let parsed = charlie.parse_msg(&auth_required).await;
assert!(parsed.get_header(HeaderDef::AutocryptGossip).is_some());
assert!(contains(&parsed, "charlie@example.net"));
assert_eq!(contains(&parsed, "bob@example.net"), false);
assert!(parsed.decoded_data_contains("charlie@example.net"));
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
let parsed_by_bob = bob.parse_msg(&auth_required).await;
assert!(parsed_by_bob.decrypting_failed);
@@ -2698,8 +2692,8 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
);
let parsed = charlie.parse_msg(&member_added).await;
assert!(parsed.get_header(HeaderDef::AutocryptGossip).is_some());
assert!(contains(&parsed, "charlie@example.net"));
assert_eq!(contains(&parsed, "bob@example.net"), false);
assert!(parsed.decoded_data_contains("charlie@example.net"));
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
let parsed_by_bob = bob.parse_msg(&member_added).await;
assert!(parsed_by_bob.decrypting_failed);
@@ -2713,8 +2707,8 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
let hi_msg = alice.send_text(alice_broadcast_id, "hi").await;
let parsed = charlie.parse_msg(&hi_msg).await;
assert_eq!(parsed.header_exists(HeaderDef::AutocryptGossip), false);
assert_eq!(contains(&parsed, "charlie@example.net"), false);
assert_eq!(contains(&parsed, "bob@example.net"), false);
assert_eq!(parsed.decoded_data_contains("charlie@example.net"), false);
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
let parsed_by_bob = bob.parse_msg(&hi_msg).await;
assert_eq!(parsed_by_bob.decrypting_failed, false);
@@ -2730,8 +2724,8 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
"charlie@example.net alice@example.org"
);
let parsed = charlie.parse_msg(&member_removed).await;
assert!(contains(&parsed, "charlie@example.net"));
assert_eq!(contains(&parsed, "bob@example.net"), false);
assert!(parsed.decoded_data_contains("charlie@example.net"));
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
let parsed_by_bob = bob.parse_msg(&member_removed).await;
assert!(parsed_by_bob.decrypting_failed);

View File

@@ -438,8 +438,19 @@ pub enum Config {
/// storing the same token multiple times on the server.
EncryptedDeviceToken,
/// Enables running test hooks, e.g. see `InnerContext::pre_encrypt_mime_hook`.
/// This way is better than conditional compilation, i.e. `#[cfg(test)]`, because tests not
/// using this still run unmodified code.
TestHooks,
/// Return an error from `receive_imf_inner()` for a fully downloaded message. For tests.
FailOnReceivingFullMsg,
/// Enable composing emails with Header Protection as defined in
/// <https://www.rfc-editor.org/rfc/rfc9788.html> "Header Protection for Cryptographically
/// Protected Email".
#[strum(props(default = "1"))]
StdHeaderProtectionComposing,
}
impl Config {
@@ -665,7 +676,7 @@ impl Context {
Config::Selfavatar if value.is_empty() => None,
Config::Selfavatar => {
config_value = BlobObject::store_from_base64(self, value)?;
Some(config_value.as_str())
config_value.as_deref()
}
_ => Some(value),
};

View File

@@ -223,6 +223,9 @@ pub(crate) const DC_FOLDERS_CONFIGURED_VERSION: i32 = 5;
// `max_smtp_rcpt_to` in the provider db.
pub(crate) const DEFAULT_MAX_SMTP_RCPT_TO: usize = 50;
/// Same as `DEFAULT_MAX_SMTP_RCPT_TO`, but for chatmail relays.
pub(crate) const DEFAULT_CHATMAIL_MAX_SMTP_RCPT_TO: usize = 999;
/// How far the last quota check needs to be in the past to be checked by the background function (in seconds).
pub(crate) const DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT: u64 = 12 * 60 * 60; // 12 hours

View File

@@ -369,16 +369,15 @@ async fn import_vcard_contact(context: &Context, contact: &VcardContact) -> Resu
return Ok(id);
}
let path = match &contact.profile_image {
Some(image) => match BlobObject::store_from_base64(context, image) {
Err(e) => {
Some(image) => match BlobObject::store_from_base64(context, image)? {
None => {
warn!(
context,
"import_vcard_contact: Could not decode and save avatar for {}: {e:#}.",
contact.addr
"import_vcard_contact: Could not decode avatar for {}.", contact.addr
);
None
}
Ok(path) => Some(path),
Some(path) => Some(path),
},
None => None,
};
@@ -1574,11 +1573,24 @@ impl Contact {
}
/// Returns a color for the contact.
/// See [`self::get_color`].
/// For self-contact this returns gray if own keypair doesn't exist yet.
/// See also [`self::get_color`].
pub fn get_color(&self) -> u32 {
get_color(self.id == ContactId::SELF, &self.addr, &self.fingerprint())
}
/// Returns a color for the contact.
/// Ensures that the color isn't gray. For self-contact this generates own keypair if it doesn't
/// exist yet.
/// See also [`self::get_color`].
pub async fn get_or_gen_color(&self, context: &Context) -> Result<u32> {
let mut fpr = self.fingerprint();
if fpr.is_none() && self.id == ContactId::SELF {
fpr = Some(load_self_public_key(context).await?.dc_fingerprint());
}
Ok(get_color(self.id == ContactId::SELF, &self.addr, &fpr))
}
/// Gets the contact's status.
///
/// Status is the last signature received in a message from this contact.

View File

@@ -4,7 +4,6 @@ use super::*;
use crate::chat::{Chat, get_chat_contacts, send_text_msg};
use crate::chatlist::Chatlist;
use crate::receive_imf::receive_imf;
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::{self, TestContext, TestContextManager, TimeShiftFalsePositiveNote};
#[test]
@@ -775,16 +774,21 @@ async fn test_contact_get_color() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_self_color_vs_key() -> Result<()> {
async fn test_self_color() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.unconfigured().await;
t.configure_addr("alice@example.org").await;
assert!(t.is_configured().await?);
let color = Contact::get_by_id(t, ContactId::SELF).await?.get_color();
let self_contact = Contact::get_by_id(t, ContactId::SELF).await?;
let color = self_contact.get_color();
assert_eq!(color, 0x808080);
get_securejoin_qr(t, None).await?;
let color1 = Contact::get_by_id(t, ContactId::SELF).await?.get_color();
assert_ne!(color1, color);
let color = self_contact.get_or_gen_color(t).await?;
assert_ne!(color, 0x808080);
let color1 = self_contact.get_or_gen_color(t).await?;
assert_eq!(color1, color);
let bob = &tcm.bob().await;
assert_eq!(bob.add_or_lookup_contact(t).await.get_color(), color);
Ok(())
}

View File

@@ -303,6 +303,17 @@ pub struct InnerContext {
/// `Connectivity` values for mailboxes, unordered. Used to compute the aggregate connectivity,
/// see [`Context::get_connectivity()`].
pub(crate) connectivities: parking_lot::Mutex<Vec<ConnectivityStore>>,
#[expect(clippy::type_complexity)]
/// Transforms the root of the cryptographic payload before encryption.
pub(crate) pre_encrypt_mime_hook: parking_lot::Mutex<
Option<
for<'a> fn(
&Context,
mail_builder::mime::MimePart<'a>,
) -> mail_builder::mime::MimePart<'a>,
>,
>,
}
/// The state of ongoing process.
@@ -467,6 +478,7 @@ impl Context {
iroh: Arc::new(RwLock::new(None)),
self_fingerprint: OnceLock::new(),
connectivities: parking_lot::Mutex::new(Vec::new()),
pre_encrypt_mime_hook: None.into(),
};
let ctx = Context {
@@ -545,7 +557,7 @@ impl Context {
.and_then(|provider| provider.opt.max_smtp_rcpt_to)
.map_or_else(
|| match is_chatmail {
true => usize::MAX,
true => constants::DEFAULT_CHATMAIL_MAX_SMTP_RCPT_TO,
false => constants::DEFAULT_MAX_SMTP_RCPT_TO,
},
usize::from,
@@ -1051,6 +1063,13 @@ impl Context {
.await?
.to_string(),
);
res.insert(
"test_hooks",
self.sql
.get_raw_config("test_hooks")
.await?
.unwrap_or_default(),
);
res.insert(
"fail_on_receiving_full_msg",
self.sql
@@ -1058,6 +1077,13 @@ impl Context {
.await?
.unwrap_or_default(),
);
res.insert(
"std_header_protection_composing",
self.sql
.get_raw_config("std_header_protection_composing")
.await?
.unwrap_or_default(),
);
let elapsed = time_elapsed(&self.creation_time);
res.insert("uptime", duration_to_str(elapsed));

View File

@@ -13,6 +13,7 @@ use quick_xml::{
use crate::simplify::{SimplifiedText, simplify_quote};
#[derive(Default)]
struct Dehtml {
strbuilder: String,
quote: String,
@@ -25,6 +26,9 @@ struct Dehtml {
/// Everything between `<div name="quote">` and `<div name="quoted-content">` is usually metadata
/// If this is > `0`, then we are inside a `<div name="quoted-content">`.
divs_since_quoted_content_div: u32,
/// `<div class="header-protection-legacy-display">` elements should be omitted, see
/// <https://www.rfc-editor.org/rfc/rfc9788.html#section-4.5.3.3>.
divs_since_hp_legacy_display: u32,
/// All-Inkl just puts the quote into `<blockquote> </blockquote>`. This count is
/// increased at each `<blockquote>` and decreased at each `</blockquote>`.
blockquotes_since_blockquote: u32,
@@ -48,20 +52,25 @@ impl Dehtml {
}
fn get_add_text(&self) -> AddText {
if self.divs_since_quote_div > 0 && self.divs_since_quoted_content_div == 0 {
AddText::No // Everything between `<div name="quoted">` and `<div name="quoted_content">` is metadata which we don't want
// Everything between `<div name="quoted">` and `<div name="quoted_content">` is
// metadata which we don't want.
if self.divs_since_quote_div > 0 && self.divs_since_quoted_content_div == 0
|| self.divs_since_hp_legacy_display > 0
{
AddText::No
} else {
self.add_text
}
}
}
#[derive(Debug, PartialEq, Clone, Copy)]
#[derive(Debug, Default, PartialEq, Clone, Copy)]
enum AddText {
/// Inside `<script>`, `<style>` and similar tags
/// which contents should not be displayed.
No,
#[default]
YesRemoveLineEnds,
/// Inside `<pre>`.
@@ -121,12 +130,7 @@ fn dehtml_quick_xml(buf: &str) -> (String, String) {
let mut dehtml = Dehtml {
strbuilder: String::with_capacity(buf.len()),
quote: String::new(),
add_text: AddText::YesRemoveLineEnds,
last_href: None,
divs_since_quote_div: 0,
divs_since_quoted_content_div: 0,
blockquotes_since_blockquote: 0,
..Default::default()
};
let mut reader = quick_xml::Reader::from_str(buf);
@@ -244,6 +248,7 @@ fn dehtml_endtag_cb(event: &BytesEnd, dehtml: &mut Dehtml) {
"div" => {
pop_tag(&mut dehtml.divs_since_quote_div);
pop_tag(&mut dehtml.divs_since_quoted_content_div);
pop_tag(&mut dehtml.divs_since_hp_legacy_display);
*dehtml.get_buf() += "\n\n";
dehtml.add_text = AddText::YesRemoveLineEnds;
@@ -295,6 +300,8 @@ fn dehtml_starttag_cb<B: std::io::BufRead>(
"div" => {
maybe_push_tag(event, reader, "quote", &mut dehtml.divs_since_quote_div);
maybe_push_tag(event, reader, "quoted-content", &mut dehtml.divs_since_quoted_content_div);
maybe_push_tag(event, reader, "header-protection-legacy-display",
&mut dehtml.divs_since_hp_legacy_display);
*dehtml.get_buf() += "\n\n";
dehtml.add_text = AddText::YesRemoveLineEnds;
@@ -539,6 +546,27 @@ mod tests {
assert_eq!(txt.text.trim(), "two\nlines");
}
#[test]
fn test_hp_legacy_display() {
let input = r#"
<html><head><title></title></head><body>
<div class="header-protection-legacy-display">
<pre>Subject: Dinner plans</pre>
</div>
<p>
Let's meet at Rama's Roti Shop at 8pm and go to the park
from there.
</p>
</body>
</html>
"#;
let txt = dehtml(input).unwrap();
assert_eq!(
txt.text.trim(),
"Let's meet at Rama's Roti Shop at 8pm and go to the park from there."
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_quote_div() {
let input = include_str!("../test-data/message/gmx-quote-body.eml");

View File

@@ -241,10 +241,9 @@ pub(crate) async fn stock_ephemeral_timer_changed(
match timer {
Timer::Disabled => stock_str::msg_ephemeral_timer_disabled(context, from_id).await,
Timer::Enabled { duration } => match duration {
0..=59 => {
0..=60 => {
stock_str::msg_ephemeral_timer_enabled(context, &timer.to_string(), from_id).await
}
60 => stock_str::msg_ephemeral_timer_minute(context, from_id).await,
61..=3599 => {
stock_str::msg_ephemeral_timer_minutes(
context,

View File

@@ -38,7 +38,7 @@ async fn test_stock_ephemeral_messages() {
assert_eq!(
stock_ephemeral_timer_changed(&context, Timer::Enabled { duration: 60 }, ContactId::SELF)
.await,
"You set message deletion timer to 1 minute."
"You set message deletion timer to 60 s."
);
assert_eq!(
stock_ephemeral_timer_changed(&context, Timer::Enabled { duration: 90 }, ContactId::SELF)
@@ -142,7 +142,7 @@ async fn test_ephemeral_enable_disable() -> Result<()> {
let bob_received_message = bob.recv_msg(&sent).await;
assert_eq!(
bob_received_message.text,
"Message deletion timer is set to 1 minute by alice@example.org."
"Message deletion timer is set to 60 s by alice@example.org."
);
assert_eq!(
chat_bob.get_ephemeral_timer(bob).await?,

View File

@@ -138,6 +138,9 @@ pub enum HeaderDef {
/// Advertised gossip topic for one webxdc.
IrohGossipTopic,
/// See <https://www.rfc-editor.org/rfc/rfc9788.html#name-hp-outer-header-field>.
HpOuter,
#[cfg(test)]
TestHeader,
}

View File

@@ -377,7 +377,15 @@ async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
res = check_backup_version(context).await;
}
if res.is_ok() {
res = adjust_bcc_self(context).await;
// All recent backups have `bcc_self` set to "1" before export.
//
// Setting `bcc_self` to "1" on export was introduced on 2024-12-17
// in commit 21664125d798021be75f47d5b0d5006d338b4531
//
// We additionally try to set `bcc_self` to "1" after import here
// for compatibility with older backups,
// but eventually this code can be removed.
res = context.set_config(Config::BccSelf, Some("1")).await;
}
fs::remove_file(unpacked_database)
.await
@@ -751,7 +759,7 @@ async fn export_database(
.to_str()
.with_context(|| format!("path {} is not valid unicode", dest.display()))?;
adjust_bcc_self(context).await?;
context.set_config(Config::BccSelf, Some("1")).await?;
context
.sql
.set_raw_config_int("backup_time", timestamp)
@@ -785,18 +793,6 @@ async fn export_database(
.await
}
/// Sets `Config::BccSelf` (and `DeleteServerAfter` to "never" in effect) if needed so that new
/// messages are present on the server after a backup restoration or available for all devices in
/// multi-device case. NB: Calling this after a backup import isn't reliable as we can crash in
/// between, but this is a problem only for old backups, new backups already have `BccSelf` set if
/// necessary.
async fn adjust_bcc_self(context: &Context) -> Result<()> {
if context.is_chatmail().await? && !context.config_exists(Config::BccSelf).await? {
context.set_config(Config::BccSelf, Some("1")).await?;
}
Ok(())
}
async fn check_backup_version(context: &Context) -> Result<()> {
let version = (context.sql.get_raw_config_int("backup_version").await?).unwrap_or(2);
ensure!(

View File

@@ -1440,7 +1440,15 @@ pub(crate) fn guess_msgtype_from_path_suffix(path: &Path) -> Option<(Viewtype, &
let extension: &str = &path.extension()?.to_str()?.to_lowercase();
let info = match extension {
// before using viewtype other than Viewtype::File,
// make sure, all target UIs support that type in the context of the used viewer/player.
// make sure, all target UIs support that type.
//
// it is a non-goal to support as many formats as possible in-app.
// additional parser come at security and maintainance costs and
// should only be added when strictly neccessary,
// eg. when a format comes from the camera app on a significant number of devices.
// it is okay, when eg. dragging some video from a browser results in a "File"
// for everyone, sender as well as all receivers.
//
// if in doubt, it is better to default to Viewtype::File that passes handing to an external app.
// (cmp. <https://developer.android.com/guide/topics/media/media-formats>)
"3gp" => (Viewtype::Video, "video/3gpp"),
@@ -1503,7 +1511,7 @@ pub(crate) fn guess_msgtype_from_path_suffix(path: &Path) -> Option<(Viewtype, &
"vcf" => (Viewtype::Vcard, "text/vcard"),
"wav" => (Viewtype::Audio, "audio/wav"),
"weba" => (Viewtype::File, "audio/webm"),
"webm" => (Viewtype::Video, "video/webm"),
"webm" => (Viewtype::File, "video/webm"), // not supported natively by iOS nor by SDWebImage
"webp" => (Viewtype::Image, "image/webp"), // iOS via SDWebImage, Android since 4.0
"wmv" => (Viewtype::Video, "video/x-ms-wmv"),
"xdc" => (Viewtype::Webxdc, "application/webxdc+zip"),

View File

@@ -1083,6 +1083,9 @@ impl MimeFactory {
}
}
let use_std_header_protection = context
.get_config_bool(Config::StdHeaderProtectionComposing)
.await?;
let outer_message = if let Some(encryption_pubkeys) = self.encryption_pubkeys {
// Store protected headers in the inner message.
let message = protected_headers
@@ -1098,6 +1101,22 @@ impl MimeFactory {
message.header(header, value)
});
if use_std_header_protection {
message = unprotected_headers
.iter()
// Structural headers shouldn't be added as "HP-Outer". They are defined in
// <https://www.rfc-editor.org/rfc/rfc9787.html#structural-header-fields>.
.filter(|(name, _)| {
!(name.eq_ignore_ascii_case("mime-version")
|| name.eq_ignore_ascii_case("content-type")
|| name.eq_ignore_ascii_case("content-transfer-encoding")
|| name.eq_ignore_ascii_case("content-disposition"))
})
.fold(message, |message, (name, value)| {
message.header(format!("HP-Outer: {name}"), value.clone())
});
}
// Add gossip headers in chats with multiple recipients
let multiple_recipients =
encryption_pubkeys.len() > 1 || context.get_config_bool(Config::BccSelf).await?;
@@ -1187,7 +1206,13 @@ impl MimeFactory {
for (h, v) in &mut message.headers {
if h == "Content-Type" {
if let mail_builder::headers::HeaderType::ContentType(ct) = v {
*ct = ct.clone().attribute("protected-headers", "v1");
let mut ct_new = ct.clone();
ct_new = ct_new.attribute("protected-headers", "v1");
if use_std_header_protection {
ct_new = ct_new.attribute("hp", "cipher");
}
*ct = ct_new;
break;
}
}
}
@@ -1232,6 +1257,12 @@ impl MimeFactory {
// once new core versions are sufficiently deployed.
let anonymous_recipients = false;
if context.get_config_bool(Config::TestHooks).await? {
if let Some(hook) = &*context.pre_encrypt_mime_hook.lock() {
message = hook(context, message);
}
}
let encrypted = if let Some(shared_secret) = shared_secret {
encrypt_helper
.encrypt_symmetrically(context, &shared_secret, message, compress)
@@ -1319,7 +1350,13 @@ impl MimeFactory {
for (h, v) in &mut message.headers {
if h == "Content-Type" {
if let mail_builder::headers::HeaderType::ContentType(ct) = v {
*ct = ct.clone().attribute("protected-headers", "v1");
let mut ct_new = ct.clone();
ct_new = ct_new.attribute("protected-headers", "v1");
if use_std_header_protection {
ct_new = ct_new.attribute("hp", "clear");
}
*ct = ct_new;
break;
}
}
}

View File

@@ -832,8 +832,32 @@ async fn test_protected_headers_directive() -> Result<()> {
.count(),
1
);
assert_eq!(part.match_indices("Subject:").count(), 1);
assert_eq!(part.match_indices("Subject:").count(), 2);
assert_eq!(part.match_indices("HP-Outer: Subject:").count(), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_hp_outer_headers() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.alice().await;
let chat_id = t.get_self_chat().await.id;
for std_hp_composing in [false, true] {
t.set_config_bool(Config::StdHeaderProtectionComposing, std_hp_composing)
.await?;
chat::send_text_msg(t, chat_id, "hi!".to_string()).await?;
let sent_msg = t.pop_sent_msg().await;
let msg = MimeMessage::from_bytes(t, sent_msg.payload.as_bytes(), None).await?;
assert_eq!(msg.header_exists(HeaderDef::HpOuter), std_hp_composing);
for hdr in ["Date", "From", "Message-ID"] {
assert_eq!(
msg.decoded_data_contains(&format!("HP-Outer: {hdr}:")),
std_hp_composing,
);
}
assert!(!msg.decoded_data_contains("HP-Outer: Content-Type"));
}
Ok(())
}

View File

@@ -271,7 +271,7 @@ impl MimeMessage {
&mut from,
&mut list_post,
&mut chat_disposition_notification_to,
&mail.headers,
&mail,
);
headers.retain(|k, _| {
!is_hidden(k) || {
@@ -299,7 +299,7 @@ impl MimeMessage {
&mut from,
&mut list_post,
&mut chat_disposition_notification_to,
&part.headers,
part,
);
(part, part.ctype.mimetype.parse::<Mime>()?)
} else {
@@ -536,7 +536,7 @@ impl MimeMessage {
&mut inner_from,
&mut list_post,
&mut chat_disposition_notification_to,
&mail.headers,
mail,
);
if !signatures.is_empty() {
@@ -714,14 +714,16 @@ impl MimeMessage {
}
/// Parses avatar action headers.
fn parse_avatar_headers(&mut self, context: &Context) {
fn parse_avatar_headers(&mut self, context: &Context) -> Result<()> {
if let Some(header_value) = self.get_header(HeaderDef::ChatGroupAvatar) {
self.group_avatar = self.avatar_action_from_header(context, header_value.to_string());
self.group_avatar =
self.avatar_action_from_header(context, header_value.to_string())?;
}
if let Some(header_value) = self.get_header(HeaderDef::ChatUserAvatar) {
self.user_avatar = self.avatar_action_from_header(context, header_value.to_string());
self.user_avatar = self.avatar_action_from_header(context, header_value.to_string())?;
}
Ok(())
}
fn parse_videochat_headers(&mut self) {
@@ -828,7 +830,7 @@ impl MimeMessage {
async fn parse_headers(&mut self, context: &Context) -> Result<()> {
self.parse_system_message_headers(context);
self.parse_avatar_headers(context);
self.parse_avatar_headers(context)?;
self.parse_videochat_headers();
if self.delivery_report.is_none() {
self.squash_attachment_parts();
@@ -930,21 +932,18 @@ impl MimeMessage {
&mut self,
context: &Context,
header_value: String,
) -> Option<AvatarAction> {
if header_value == "0" {
) -> Result<Option<AvatarAction>> {
let res = if header_value == "0" {
Some(AvatarAction::Delete)
} else if let Some(base64) = header_value
.split_ascii_whitespace()
.collect::<String>()
.strip_prefix("base64:")
{
match BlobObject::store_from_base64(context, base64) {
Ok(path) => Some(AvatarAction::Change(path)),
Err(err) => {
warn!(
context,
"Could not decode and save avatar to blob file: {:#}", err,
);
match BlobObject::store_from_base64(context, base64)? {
Some(path) => Some(AvatarAction::Change(path)),
None => {
warn!(context, "Could not decode avatar base64");
None
}
}
@@ -958,7 +957,7 @@ impl MimeMessage {
if let Some(blob) = part.param.get(Param::File) {
let res = Some(AvatarAction::Change(blob.to_string()));
self.parts.remove(i);
return res;
return Ok(res);
}
break;
}
@@ -966,7 +965,8 @@ impl MimeMessage {
i += 1;
}
None
}
};
Ok(res)
}
/// Returns true if the message was encrypted as defined in
@@ -1007,6 +1007,14 @@ impl MimeMessage {
self.headers.contains_key(hname) || self.headers_removed.contains(hname)
}
#[cfg(test)]
/// Returns whether the decrypted data contains the given `&str`.
pub(crate) fn decoded_data_contains(&self, s: &str) -> bool {
assert!(!self.decrypting_failed);
let decoded_str = str::from_utf8(&self.decoded_data).unwrap();
decoded_str.contains(s)
}
/// Returns `Chat-Group-ID` header value if it is a valid group ID.
pub fn get_chat_group_id(&self) -> Option<&str> {
self.get_header(HeaderDef::ChatGroupId)
@@ -1323,6 +1331,10 @@ impl MimeMessage {
let is_html = mime_type == mime::TEXT_HTML;
if is_html {
self.is_mime_modified = true;
// NB: This unconditionally removes Legacy Display Elements (see
// <https://www.rfc-editor.org/rfc/rfc9788.html#section-4.5.3.3>). We
// don't check for the "hp-legacy-display" Content-Type parameter
// for simplicity.
if let Some(text) = dehtml(&decoded_data) {
text
} else {
@@ -1350,16 +1362,30 @@ impl MimeMessage {
let (simplified_txt, simplified_quote) = if mime_type.type_() == mime::TEXT
&& mime_type.subtype() == mime::PLAIN
&& is_format_flowed
{
let delsp = if let Some(delsp) = mail.ctype.params.get("delsp") {
delsp.as_str().eq_ignore_ascii_case("yes")
} else {
false
// Don't check that we're inside an encrypted or signed part for
// simplicity.
let simplified_txt = match mail
.ctype
.params
.get("hp-legacy-display")
.is_some_and(|v| v == "1")
{
false => simplified_txt,
true => rm_legacy_display_elements(&simplified_txt),
};
let unflowed_text = unformat_flowed(&simplified_txt, delsp);
let unflowed_quote = top_quote.map(|q| unformat_flowed(&q, delsp));
(unflowed_text, unflowed_quote)
if is_format_flowed {
let delsp = if let Some(delsp) = mail.ctype.params.get("delsp") {
delsp.as_str().eq_ignore_ascii_case("yes")
} else {
false
};
let unflowed_text = unformat_flowed(&simplified_txt, delsp);
let unflowed_quote = top_quote.map(|q| unformat_flowed(&q, delsp));
(unflowed_text, unflowed_quote)
} else {
(simplified_txt, top_quote)
}
} else {
(simplified_txt, top_quote)
};
@@ -1632,6 +1658,11 @@ impl MimeMessage {
}
}
/// Merges headers from the email `part` into `headers` respecting header protection.
/// Should only be called with nonempty `headers` if `part` is a root of the Cryptographic
/// Payload as defined in <https://www.rfc-editor.org/rfc/rfc9788.html> "Header Protection for
/// Cryptographically Protected Email", otherwise this may unnecessarily discard headers from
/// outer parts.
#[allow(clippy::too_many_arguments)]
fn merge_headers(
context: &Context,
@@ -1642,10 +1673,14 @@ impl MimeMessage {
from: &mut Option<SingleInfo>,
list_post: &mut Option<String>,
chat_disposition_notification_to: &mut Option<SingleInfo>,
fields: &[mailparse::MailHeader<'_>],
part: &mailparse::ParsedMail,
) {
let fields = &part.headers;
// See <https://www.rfc-editor.org/rfc/rfc9788.html>.
let has_header_protection = part.ctype.params.contains_key("hp");
headers.retain(|k, _| {
!is_protected(k) || {
!(has_header_protection || is_protected(k)) || {
headers_removed.insert(k.to_string());
false
}
@@ -1972,6 +2007,20 @@ impl MimeMessage {
}
}
fn rm_legacy_display_elements(text: &str) -> String {
let mut res = None;
for l in text.lines() {
res = res.map(|r: String| match r.is_empty() {
true => l.to_string(),
false => r + "\r\n" + l,
});
if l.is_empty() {
res = Some(String::new());
}
}
res.unwrap_or_default()
}
fn remove_header(
headers: &mut HashMap<String, String>,
key: &str,
@@ -2096,7 +2145,8 @@ pub(crate) fn parse_message_id(ids: &str) -> Result<String> {
}
/// Returns whether the outer header value must be ignored if the message contains a signed (and
/// optionally encrypted) part.
/// optionally encrypted) part. This is independent from the modern Header Protection defined in
/// <https://www.rfc-editor.org/rfc/rfc9788.html>.
///
/// NB: There are known cases when Subject and List-ID only appear in the outer headers of
/// signed-only messages. Such messages are shown as unencrypted anyway.

View File

@@ -1401,22 +1401,28 @@ async fn test_x_microsoft_original_message_id_precedence() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_extra_imf_chat_header() -> Result<()> {
async fn test_extra_imf_headers() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.alice().await;
let chat_id = t.get_self_chat().await.id;
chat::send_text_msg(t, chat_id, "hi!".to_string()).await?;
let sent_msg = t.pop_sent_msg().await;
// Check removal of some nonexistent "Chat-*" header to protect the code from future breakages.
let payload = sent_msg
.payload
.replace("Message-ID:", "Chat-Forty-Two: 42\r\nMessage-ID:");
let msg = MimeMessage::from_bytes(t, payload.as_bytes(), None)
.await
.unwrap();
assert!(msg.headers.contains_key("chat-version"));
assert!(!msg.headers.contains_key("chat-forty-two"));
for std_hp_composing in [false, true] {
t.set_config_bool(Config::StdHeaderProtectionComposing, std_hp_composing)
.await?;
chat::send_text_msg(t, chat_id, "hi!".to_string()).await?;
let sent_msg = t.pop_sent_msg().await;
// Check removal of some nonexistent "Chat-*" header to protect the code from future
// breakages. But headers not prefixed with "Chat-" remain unless a message has standard
// Header Protection.
let payload = sent_msg.payload.replace(
"Message-ID:",
"Chat-Forty-Two: 42\r\nForty-Two: 42\r\nMessage-ID:",
);
let msg = MimeMessage::from_bytes(t, payload.as_bytes(), None).await?;
assert!(msg.headers.contains_key("chat-version"));
assert!(!msg.headers.contains_key("chat-forty-two"));
assert_ne!(msg.headers.contains_key("forty-two"), std_hp_composing);
}
Ok(())
}
@@ -1751,6 +1757,39 @@ async fn test_time_in_future() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_hp_legacy_display() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let mut msg = Message::new_text(
"Subject: Dinner plans\n\
\n\
Let's eat"
.to_string(),
);
msg.set_subject("Dinner plans".to_string());
let chat_id = alice.create_chat(bob).await.id;
alice.set_config_bool(Config::TestHooks, true).await?;
*alice.pre_encrypt_mime_hook.lock() = Some(|_, mut mime| {
for (h, v) in &mut mime.headers {
if h == "Content-Type" {
if let mail_builder::headers::HeaderType::ContentType(ct) = v {
*ct = ct.clone().attribute("hp-legacy-display", "1");
}
}
}
mime
});
let sent_msg = alice.send_msg(chat_id, &mut msg).await;
let msg_bob = bob.recv_msg(&sent_msg).await;
assert_eq!(msg_bob.subject, "Dinner plans");
assert_eq!(msg_bob.text, "Let's eat");
Ok(())
}
/// Tests that subject is not prepended to the message
/// when bot receives it.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

104
src/qr.rs
View File

@@ -233,6 +233,31 @@ pub enum Qr {
authcode: String,
},
/// Ask the user if they want to withdraw their own broadcast channel invite QR code.
WithdrawJoinBroadcast {
/// The user-visible name of this broadcast channel
name: String,
/// A string of random characters,
/// uniquely identifying this broadcast channel across all databases/clients.
/// Called `grpid` for historic reasons:
/// The id of multi-user chats is always called `grpid` in the database
/// because groups were once the only multi-user chats.
grpid: String,
/// Contact ID. Always `ContactId::SELF`.
contact_id: ContactId,
/// Fingerprint of the contact's key as scanned from the QR code.
fingerprint: Fingerprint,
/// Invite number.
invitenumber: String,
/// Authentication code.
authcode: String,
},
/// Ask the user if they want to revive their own QR code.
ReviveVerifyContact {
/// Contact ID.
@@ -269,6 +294,31 @@ pub enum Qr {
authcode: String,
},
/// Ask the user if they want to revive their own broadcast channel invite QR code.
ReviveJoinBroadcast {
/// The user-visible name of this broadcast channel
name: String,
/// A string of random characters,
/// uniquely identifying this broadcast channel across all databases/clients.
/// Called `grpid` for historic reasons:
/// The id of multi-user chats is always called `grpid` in the database
/// because groups were once the only multi-user chats.
grpid: String,
/// Contact ID. Always `ContactId::SELF`.
contact_id: ContactId,
/// Fingerprint of the contact's key as scanned from the QR code.
fingerprint: Fingerprint,
/// Invite number.
invitenumber: String,
/// Authentication code.
authcode: String,
},
/// `dclogin:` scheme parameters.
///
/// Ask the user if they want to login with the email address.
@@ -500,14 +550,40 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
})
}
} else if let (Some(grpid), Some(name)) = (grpid, broadcast_name) {
Ok(Qr::AskJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
})
if context
.is_self_addr(&addr)
.await
.with_context(|| format!("Can't check if {addr:?} is our address"))?
{
if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
Ok(Qr::WithdrawJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
})
} else {
Ok(Qr::ReviveJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
})
}
} else {
Ok(Qr::AskJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
})
}
} else if context.is_self_addr(&addr).await? {
if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
Ok(Qr::WithdrawVerifyContact {
@@ -800,6 +876,12 @@ pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> {
invitenumber,
authcode,
..
}
| Qr::WithdrawJoinBroadcast {
grpid,
invitenumber,
authcode,
..
} => {
token::delete(context, &grpid).await?;
context
@@ -829,6 +911,12 @@ pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> {
authcode,
grpid,
..
}
| Qr::ReviveJoinBroadcast {
invitenumber,
authcode,
grpid,
..
} => {
let timestamp = time();
token::save(

View File

@@ -1,5 +1,5 @@
use super::*;
use crate::chat::create_group;
use crate::chat::{Chat, create_broadcast, create_group, get_chat_contacts};
use crate::config::Config;
use crate::login_param::EnteredCertificateChecks;
use crate::provider::Socket;
@@ -511,6 +511,56 @@ async fn test_withdraw_verifygroup() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_withdraw_joinbroadcast() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat_id = create_broadcast(alice, "foo".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(chat_id)).await?;
// scanning own verify-group code offers withdrawing
if let Qr::WithdrawJoinBroadcast { name, .. } = check_qr(alice, &qr).await? {
assert_eq!(name, "foo");
} else {
bail!("Wrong QR type, expected WithdrawJoinBroadcast");
}
set_config_from_qr(alice, &qr).await?;
// scanning withdrawn verify-group code offers reviving
if let Qr::ReviveJoinBroadcast { name, .. } = check_qr(alice, &qr).await? {
assert_eq!(name, "foo");
} else {
bail!("Wrong QR type, expected ReviveJoinBroadcast");
}
// someone else always scans as ask-verify-group
if let Qr::AskJoinBroadcast { name, .. } = check_qr(bob, &qr).await? {
assert_eq!(name, "foo");
} else {
bail!("Wrong QR type, expected AskJoinBroadcast");
}
assert!(set_config_from_qr(bob, &qr).await.is_err());
// Bob can't join using this QR code, since it's still withdrawn
let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await;
let bob_chat = Chat::load_from_db(bob, bob_chat_id).await?;
assert_eq!(bob_chat.is_self_in_chat(bob).await?, false);
assert_eq!(get_chat_contacts(alice, chat_id).await?.len(), 0);
// Revive
set_config_from_qr(alice, &qr).await?;
// Now Bob can join
let bob_chat_id2 = tcm.exec_securejoin_qr(bob, alice, &qr).await;
assert_eq!(bob_chat_id, bob_chat_id2);
let bob_chat = Chat::load_from_db(bob, bob_chat_id).await?;
assert_eq!(bob_chat.is_self_in_chat(bob).await?, true);
assert_eq!(get_chat_contacts(alice, chat_id).await?.len(), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_withdraw_multidevice() -> Result<()> {
let mut tcm = TestContextManager::new();

View File

@@ -454,7 +454,8 @@ impl Context {
let domain =
&deltachat_contact_tools::EmailAddress::new(&self.get_primary_self_addr().await?)?
.domain;
let storage_on_domain = stock_str::storage_on_domain(self, domain).await;
let storage_on_domain =
escaper::encode_minimal(&stock_str::storage_on_domain(self, domain).await);
ret += &format!("<h3>{storage_on_domain}</h3><ul>");
let quota = self.quota.read().await;
if let Some(quota) = &*quota {
@@ -528,11 +529,15 @@ impl Context {
}
}
} else {
ret += format!("<li>Warning: {domain} claims to support quota but gives no information</li>").as_str();
let domain_escaped = escaper::encode_minimal(domain);
ret += &format!(
"<li>Warning: {domain_escaped} claims to support quota but gives no information</li>"
);
}
}
Err(e) => {
ret += format!("<li>{e}</li>").as_str();
let error_escaped = escaper::encode_minimal(&e.to_string());
ret += &format!("<li>{error_escaped}</li>");
}
}
} else {

View File

@@ -112,7 +112,11 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option<ChatId>) -> Resul
// If the user created the broadcast before updating Delta Chat,
// then the secret will be missing, and the user needs to recreate the broadcast:
if load_broadcast_secret(context, chat.id).await?.is_none() {
warn!(context, "Not creating securejoin QR for old broadcast");
error!(
context,
"Not creating securejoin QR for old broadcast {}, see chat for more info.",
chat.id,
);
let text = BROADCAST_INCOMPATIBILITY_MSG;
add_info_msg(context, chat.id, text, time()).await?;
bail!(text.to_string());

View File

@@ -302,12 +302,6 @@ pub enum StockMessage {
#[strum(props(fallback = "Message deletion timer is set to %1$s s by %2$s."))]
MsgEphemeralTimerEnabledBy = 141,
#[strum(props(fallback = "You set message deletion timer to 1 minute."))]
MsgYouEphemeralTimerMinute = 142,
#[strum(props(fallback = "Message deletion timer is set to 1 minute by %1$s."))]
MsgEphemeralTimerMinuteBy = 143,
#[strum(props(fallback = "You set message deletion timer to 1 hour."))]
MsgYouEphemeralTimerHour = 144,
@@ -447,6 +441,9 @@ https://delta.chat/donate"))]
fallback = "You are using a proxy. If you're having trouble connecting, try a different proxy."
))]
ProxyEnabledDescription = 221,
#[strum(props(fallback = "Messages in this chat use classic email and are not encrypted."))]
ChatUnencryptedExplanation = 230,
}
impl StockMessage {
@@ -1001,17 +998,6 @@ pub(crate) async fn msg_ephemeral_timer_enabled(
}
}
/// Stock string: `Message deletion timer is set to 1 minute.`.
pub(crate) async fn msg_ephemeral_timer_minute(context: &Context, by_contact: ContactId) -> String {
if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouEphemeralTimerMinute).await
} else {
translated(context, StockMessage::MsgEphemeralTimerMinuteBy)
.await
.replace1(&by_contact.get_stock_name(context).await)
}
}
/// Stock string: `Message deletion timer is set to 1 hour.`.
pub(crate) async fn msg_ephemeral_timer_hour(context: &Context, by_contact: ContactId) -> String {
if by_contact == ContactId::SELF {
@@ -1335,6 +1321,11 @@ pub(crate) async fn proxy_description(context: &Context) -> String {
translated(context, StockMessage::ProxyEnabledDescription).await
}
/// Stock string: `Messages in this chat use classic email and are not encrypted.`.
pub(crate) async fn chat_unencrypted_explanation(context: &Context) -> String {
translated(context, StockMessage::ChatUnencryptedExplanation).await
}
impl Context {
/// Set the stock string for the [StockMessage].
///

View File

@@ -1679,7 +1679,6 @@ Until the false-positive is fixed:
}
}
#[cfg(test)]
mod tests {
use super::*;