mirror of
https://github.com/chatmail/core.git
synced 2026-10-08 22:20:17 +03:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c41cac76d2 | ||
|
|
3de65e4f0e | ||
|
|
2a5058a308 | ||
|
|
b79125b947 | ||
|
|
ed647aa49c | ||
|
|
8858c01f3f | ||
|
|
54b093d89f | ||
|
|
b6f116d26a | ||
|
|
362cc76c83 | ||
|
|
aa0800b6e0 | ||
|
|
5a5597e990 | ||
|
|
94cc8a3858 | ||
|
|
6b62fed6fa | ||
|
|
b766a9a8fa | ||
|
|
3d3a6352e9 | ||
|
|
0eddb98354 | ||
|
|
72e711e59a | ||
|
|
a144a450e1 | ||
|
|
b87cd8937a | ||
|
|
fae6a63023 | ||
|
|
54e16429b8 | ||
|
|
946120e427 | ||
|
|
e1d58706ce | ||
|
|
7876115d86 | ||
|
|
0a7e697f50 | ||
|
|
06150d6dc2 | ||
|
|
310dda494c | ||
|
|
8f39d7510f | ||
|
|
44ab013351 | ||
|
|
f7c97d8557 | ||
|
|
78caa6f53c | ||
|
|
349c9650e3 | ||
|
|
96e84d85a5 | ||
|
|
57b2c000e6 | ||
|
|
8014c2af2f | ||
|
|
5db55ac4de |
+109
@@ -1,5 +1,112 @@
|
||||
# Changelog
|
||||
|
||||
## [2.62.0] - 2026-09-22
|
||||
|
||||
### API-Changes
|
||||
|
||||
- Remove unused ImapMessageMoved event.
|
||||
- [**breaking**] get rid of `Qr::FprWithoutAddr` and `Qr::FprMismatch` variants.
|
||||
- removed `DC_QR_FPR_WITHOUT_ADDR` and `DC_QR_FPR_MISMATCH` constants from CFFI
|
||||
- removed FprWithoutAddr and FprMismatch variants from JSON-RPC QrObject returned by `check_qr`
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Don't show "member added" messages in channels ([#8730](https://github.com/chatmail/core/pull/8730)).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Don't notify about a reaction sent by a blocked contact ([#8729](https://github.com/chatmail/core/pull/8729)).
|
||||
- keep a message read status if a failure arrives later.
|
||||
- prefer login errors over connection errors in the log message ([#8727](https://github.com/chatmail/core/pull/8727)).
|
||||
- blocked or email contacts contacts are never recently seen or old ([#8733](https://github.com/chatmail/core/pull/8733)).
|
||||
|
||||
### Refactor
|
||||
|
||||
- Remove code that moves messages between IMAP folders.
|
||||
|
||||
## [2.61.0] - 2026-09-21
|
||||
|
||||
### API-Changes
|
||||
|
||||
- add `init_transports()` for multi-relay onboarding.
|
||||
- add JSON-RPC API `is_sending_finished()`.
|
||||
- [**breaking**] remove verification methods from the FFI and JSON-RPC APIs.
|
||||
- `dc_contact_is_verified()` and `dc_contact_get_verifier_id()` are removed.
|
||||
- the JSON-RPC Contact object loses the `isVerified` and `verifierId` fields. A bot reading `snapshot.is_verified` now gets an `AttributeError` at runtime.
|
||||
- the Python bindings lose `Contact.is_verified()` and `Contact.get_verifier()`.
|
||||
- `DC_STR_CONTACT_VERIFIED` (35) is removed, so UIs should stop registering a translation for it. A stock id core does not know is logged and otherwise ignored, so an un-updated client keeps working.
|
||||
- [**breaking**] remove default value for "addr" config.
|
||||
- [**breaking**] remove `addr` field from Account objects in JSON-RPC APIs.
|
||||
- `list_transports()` should be used instead.
|
||||
- [**breaking**] remove `is_chatmail` and the XCHATMAIL capability.
|
||||
- `is_chatmail` is no longer a known config key.
|
||||
- [**breaking**] remove `Contact.get_name_n_addr()` and related APIs.
|
||||
- `dc_contact_get_name_n_addr()` CFFI is removed
|
||||
- JSON-RPC contact objects don't have nameAndAddr field anymore
|
||||
- [**breaking**] replace `was_seen_recently` by `freshness` in contact object.
|
||||
- use contact's `freshness` instead of `seen_recently`
|
||||
|
||||
### Fixes
|
||||
|
||||
- always emit `AccountsBackgroundFetchDone`.
|
||||
- make `background_fetch` not wait on or trigger SMTP connections.
|
||||
- do not send a sync message when changing `configured_addr`.
|
||||
- emit `SmtpMessageSent` event after deleting the message from SMTP queue.
|
||||
- don't use extra STUN nine server for fallback.
|
||||
- use `max_smtp_rcpt_to` chunking for the actual transport we are sending from.
|
||||
- use correct `From` address when sending MDNs.
|
||||
- use correct address for Bcc-self in unencrypted mails.
|
||||
- don't emit configure progress events during background relay additions.
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- better quality of image recoding ([#8682](https://github.com/chatmail/core/pull/8682)).
|
||||
- perform background fetch from all transports.
|
||||
- queue messages for SMTP before encryption.
|
||||
- [**breaking**] stop tracking contact verification.
|
||||
- the statistics JSON sent to the self-reporting-bot on Android changes: Contacts have `encrypted` instead of `verified` and lose `transitive_chain` properties and message stats have `encrypted` instead of `verified` and `unverified_encrypted`, and securejoin invites lose `already_verified`. The collecting bot stores incoming reports verbatim but analysis will have to make sense of older and newer reports.
|
||||
- remove last usage of XDELTAPUSH capability.
|
||||
- base server-side message deletion on `force_encryption`.
|
||||
- do not restart I/O when setting `configured_addr`.
|
||||
- do not use ConfiguredAddr when connecting to SMTP.
|
||||
- mark autorelays for relay operators ([#8701](https://github.com/chatmail/core/pull/8701)).
|
||||
- try fasted relays to attempt first configure on.
|
||||
- do not mark message as failed for which we got a read receipt before.
|
||||
- a single NDN does not mark a group message as failed.
|
||||
|
||||
### Documentation
|
||||
|
||||
- update `sys.msgsize_max_recommended` documentation.
|
||||
- add hint about how to reset an invitation ([#8160](https://github.com/chatmail/core/pull/8160)).
|
||||
- mention `get_app_version()` API in the changelog for 2.59.0.
|
||||
- remove `protect_autocrypt` setting.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- cleanup "primary" wording in comments.
|
||||
- update rustls to 0.23.45.
|
||||
- fix nightly "cargo" warnings.
|
||||
- fix some types in `deltachat_rpc_client`.
|
||||
|
||||
### Refactor
|
||||
|
||||
- add `Encryption.is_encrypted()`.
|
||||
- separate QueuedEncryption.
|
||||
- sql: disable double-quoted string literals.
|
||||
- add smtp::queue module.
|
||||
- substitute configure progress macro with simple function call.
|
||||
|
||||
### Tests
|
||||
|
||||
- cleanup `get_smtp_rows_for_msg()`.
|
||||
- cross-core securejoin invites for every chat type.
|
||||
- explicitly empty url in appversion updates ([#8702](https://github.com/chatmail/core/pull/8702)).
|
||||
- do not talk about "inconsistent key state" in `test_securejoin_after_contact_resetup`.
|
||||
- rename `test_aeap_transition_{0,1}` to `test_aeap_transition_{single,group}`.
|
||||
- do not fetch all messages when `direct_imap` is created.
|
||||
- `direct_imap`: always pass `mark_seen=False` to fetch().
|
||||
- add pseudo transport explicitly rather than by setting ConfiguredAddr.
|
||||
|
||||
## [2.60.0] - 2026-09-11
|
||||
|
||||
### API-Changes
|
||||
@@ -8794,3 +8901,5 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
|
||||
[2.58.0]: https://github.com/chatmail/core/compare/v2.57.0..v2.58.0
|
||||
[2.59.0]: https://github.com/chatmail/core/compare/v2.58.0..v2.59.0
|
||||
[2.60.0]: https://github.com/chatmail/core/compare/v2.59.0..v2.60.0
|
||||
[2.61.0]: https://github.com/chatmail/core/compare/v2.60.0..v2.61.0
|
||||
[2.62.0]: https://github.com/chatmail/core/compare/v2.61.0..v2.62.0
|
||||
|
||||
Generated
+6
-6
@@ -1327,7 +1327,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat"
|
||||
version = "2.61.0-dev"
|
||||
version = "2.62.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -1435,7 +1435,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat-jsonrpc"
|
||||
version = "2.61.0-dev"
|
||||
version = "2.62.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel 2.5.0",
|
||||
@@ -1456,14 +1456,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat-jsonrpc-bindings"
|
||||
version = "2.61.0-dev"
|
||||
version = "2.62.0"
|
||||
dependencies = [
|
||||
"deltachat-jsonrpc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deltachat-repl"
|
||||
version = "2.61.0-dev"
|
||||
version = "2.62.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deltachat",
|
||||
@@ -1479,7 +1479,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat-rpc-server"
|
||||
version = "2.61.0-dev"
|
||||
version = "2.62.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deltachat",
|
||||
@@ -1508,7 +1508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat_ffi"
|
||||
version = "2.61.0-dev"
|
||||
version = "2.62.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deltachat",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat"
|
||||
version = "2.61.0-dev"
|
||||
version = "2.62.0"
|
||||
edition = "2024"
|
||||
license = "MPL-2.0"
|
||||
rust-version = "1.89"
|
||||
|
||||
@@ -104,7 +104,7 @@ pub fn sanitize_name_and_addr(name: &str, addr: &str) -> (String, String) {
|
||||
let mut name = sanitize_name(name);
|
||||
|
||||
// If the 'display name' is just the address, remove it:
|
||||
// Otherwise, the contact would sometimes be shown as "alice@example.com (alice@example.com)" (see `get_name_n_addr()`).
|
||||
// Otherwise, the contact would sometimes be shown as "alice@example.com (alice@example.com)".
|
||||
// If the display name is empty, DC will just show the address when it needs a display name.
|
||||
if name == addr {
|
||||
name = "".to_string();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat_ffi"
|
||||
version = "2.61.0-dev"
|
||||
version = "2.62.0"
|
||||
description = "Deltachat FFI"
|
||||
edition = "2024"
|
||||
license = "MPL-2.0"
|
||||
|
||||
+73
-55
@@ -453,18 +453,9 @@ char* dc_get_blobdir (const dc_context_t* context);
|
||||
* always auto-downloaded.
|
||||
* 0 = no limit (default).
|
||||
* Changes affect future messages only.
|
||||
* - `protect_autocrypt` = Enable Header Protection for Autocrypt header.
|
||||
* This is an experimental option not compatible to other MUAs
|
||||
* and older Delta Chat versions.
|
||||
* 1 = enable.
|
||||
* 0 = disable (default).
|
||||
* - `gossip_period` = How often to gossip Autocrypt keys in chats with multiple recipients, in
|
||||
* seconds. 2 days by default.
|
||||
* This is not supposed to be changed by UIs and only used for testing.
|
||||
* - `is_chatmail` = (deprecated) 1 if the the server is a chatmail server, 0 otherwise.
|
||||
* This is deprecated, UIs should not behave differently
|
||||
* for chatmail relays and classical email servers.
|
||||
* Most usages in UIs can be replaced by `force_encryption`.
|
||||
* - `is_muted` = Whether a context is muted by the user.
|
||||
* Muted contexts should not sound, vibrate or show notifications.
|
||||
* In contrast to `dc_set_chat_mute_duration()`,
|
||||
@@ -697,6 +688,21 @@ char* dc_get_connectivity_html (dc_context_t* context);
|
||||
void dc_configure (dc_context_t* context);
|
||||
|
||||
|
||||
/**
|
||||
* Add fake transport that cannot be used to connect.
|
||||
*
|
||||
* Used for offline tests only.
|
||||
*
|
||||
* To add a transport, use JSON-RPC calls `add_or_update_transport`
|
||||
* and `add_transport_from_qr` instead.
|
||||
*
|
||||
* @memberof dc_context_t
|
||||
* @param context The context object.
|
||||
* @param addr The email address of the new transport.
|
||||
*/
|
||||
void dc_add_pseudo_transport (dc_context_t* context, const char *addr);
|
||||
|
||||
|
||||
/**
|
||||
* Check if the context is already configured.
|
||||
*
|
||||
@@ -2449,8 +2455,6 @@ void dc_stop_ongoing_process (dc_context_t* context);
|
||||
#define DC_QR_ASK_VERIFYGROUP 202 // text1=groupname
|
||||
#define DC_QR_ASK_VERIFYBROADCAST 204 // text1=broadcast name
|
||||
#define DC_QR_FPR_OK 210 // id=contact
|
||||
#define DC_QR_FPR_MISMATCH 220 // id=contact
|
||||
#define DC_QR_FPR_WITHOUT_ADDR 230 // test1=formatted fingerprint
|
||||
#define DC_QR_ACCOUNT 250 // text1=domain
|
||||
#define DC_QR_BACKUP2 252
|
||||
#define DC_QR_BACKUP_TOO_NEW 255
|
||||
@@ -2491,13 +2495,6 @@ void dc_stop_ongoing_process (dc_context_t* context);
|
||||
* ask the user if they want to start chatting;
|
||||
* if so, call dc_create_chat_by_contact_id().
|
||||
*
|
||||
* - DC_QR_FPR_MISMATCH with dc_lot_t::id=Contact ID:
|
||||
* scanned fingerprint does not match last seen fingerprint.
|
||||
*
|
||||
* - DC_QR_FPR_WITHOUT_ADDR with dc_lot_t::text1=Formatted fingerprint
|
||||
* the scanned QR code contains a fingerprint but no e-mail address;
|
||||
* suggest the user to establish an encrypted connection first.
|
||||
*
|
||||
* - DC_QR_ACCOUNT dc_lot_t::text1=domain:
|
||||
* ask the user if they want to create an account on the given domain,
|
||||
* if so, call dc_set_config_from_qr() and then dc_configure().
|
||||
@@ -3187,17 +3184,28 @@ void dc_accounts_maybe_network_lost (dc_accounts_t* accounts);
|
||||
|
||||
/**
|
||||
* Perform a background fetch for all accounts in parallel with a timeout.
|
||||
* Pauses the scheduler, fetches from all transports at once and then resumes the scheduler.
|
||||
* The fetch for an account ends as soon as one of its transports received messages.
|
||||
*
|
||||
* dc_accounts_background_fetch() was created for the iOS Background fetch.
|
||||
* For an account with IO stopped, the scheduler is paused
|
||||
* and every transport is fetched concurrently on a dedicated connection.
|
||||
* The account is done as soon as one transport received messages, the others stop.
|
||||
* Only one batch of messages is fetched per transport this way,
|
||||
* so a larger backlog is left to the next call or to started IO.
|
||||
*
|
||||
* For an account with IO running, IMAP IDLE is interrupted on every transport
|
||||
* and the account is done once every transport is.
|
||||
*
|
||||
* The call never waits for outgoing messages and never triggers sending them itself.
|
||||
* Received messages may still queue replies, securejoin handshakes for example,
|
||||
* which go out only while IO is running.
|
||||
*
|
||||
* The `DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE` event is emitted at the end,
|
||||
* also on timeout, when another background fetch is already running
|
||||
* and when the call is ignored because the timeout is too small,
|
||||
* so it is safe to wait for the event whenever `accounts` is not NULL.
|
||||
* 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.
|
||||
* without forgetting to create a generic notification if no message was fetched.
|
||||
* The event carries no data identifying the call it belongs to,
|
||||
* so it marks your own call only if no concurrent background fetch is happening.
|
||||
*
|
||||
* @memberof dc_accounts_t
|
||||
* @param accounts The account manager as created by dc_accounts_new().
|
||||
@@ -4895,6 +4903,38 @@ uint32_t dc_msg_get_saved_msg_id (const dc_msg_t* msg);
|
||||
int dc_msg_is_pinned (const dc_msg_t* msg);
|
||||
|
||||
|
||||
/**
|
||||
* @defgroup DC_FRESHNESS DC_FRESHNESS
|
||||
*
|
||||
* These constants describe the freshness of a contact,
|
||||
* as returned by dc_contact_get_freshness().
|
||||
*
|
||||
* @addtogroup DC_FRESHNESS
|
||||
* @{
|
||||
*/
|
||||
|
||||
/**
|
||||
* Contact shall not be highlighted, e.g. neither shown with a "seen recently" dot
|
||||
* nor with a "not seen for a long time" hint.
|
||||
*/
|
||||
#define DC_FRESHNESS_NORMAL 0
|
||||
|
||||
/**
|
||||
* Contact was seen recently, the UI shall highlight it e.g. with a little green dot on the avatar.
|
||||
*/
|
||||
#define DC_FRESHNESS_RECENTLY_SEEN 1
|
||||
|
||||
/**
|
||||
* Contact was not seen for a long time, the UI shall highlight it e.g. with a string
|
||||
* below the contact name (e.g. "Seen 2 months ago").
|
||||
*/
|
||||
#define DC_FRESHNESS_OLD 2
|
||||
|
||||
/**
|
||||
* @}
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* @class dc_contact_t
|
||||
*
|
||||
@@ -4910,7 +4950,6 @@ uint32_t dc_msg_get_saved_msg_id (const dc_msg_t* msg);
|
||||
* By default, these names are equal,
|
||||
* but functions working with contact names
|
||||
* (e.g. dc_contact_get_name(), dc_contact_get_display_name(),
|
||||
* dc_contact_get_name_n_addr(),
|
||||
* dc_create_contact() or dc_add_address_book())
|
||||
* only affect the given-name.
|
||||
*/
|
||||
@@ -4960,7 +4999,7 @@ char* dc_contact_get_addr (const dc_contact_t* contact);
|
||||
* The function does not return the contact name as received from the network.
|
||||
*
|
||||
* This name is typically used in a form where the user can edit the name of a contact.
|
||||
* To get a fine name to display in lists etc., use dc_contact_get_display_name() or dc_contact_get_name_n_addr().
|
||||
* To get a fine name to display in lists etc., use dc_contact_get_display_name().
|
||||
*
|
||||
* @memberof dc_contact_t
|
||||
* @param contact The contact object.
|
||||
@@ -5015,23 +5054,6 @@ char* dc_contact_get_display_name (const dc_contact_t* contact);
|
||||
#define dc_contact_get_first_name dc_contact_get_display_name
|
||||
|
||||
|
||||
/**
|
||||
* Get a summary of name and address.
|
||||
*
|
||||
* The returned string is either "Name (email@domain.com)" or just
|
||||
* "email@domain.com" if the name is unset.
|
||||
*
|
||||
* The summary is typically used when asking the user something about the contact.
|
||||
* The attached e-mail address makes the question unique, e.g. "Chat with Alan Miller (am@uniquedomain.com)?"
|
||||
*
|
||||
* @memberof dc_contact_t
|
||||
* @param contact The contact object.
|
||||
* @return A summary string, must be released using dc_str_unref().
|
||||
* Never returns NULL.
|
||||
*/
|
||||
char* dc_contact_get_name_n_addr (const dc_contact_t* contact);
|
||||
|
||||
|
||||
/**
|
||||
* Get the contact's profile image.
|
||||
* This is the image set by each remote user on their own
|
||||
@@ -5085,18 +5107,18 @@ int64_t dc_contact_get_last_seen (const dc_contact_t* contact);
|
||||
|
||||
|
||||
/**
|
||||
* Check if the contact was seen recently.
|
||||
* Get the contact's freshness.
|
||||
*
|
||||
* The UI shall hightlight contacts that are recently seen by a little green dot on the avatar
|
||||
* and contacts that were not seen for a long time by a string below the contact name (e.g. "Seen 2 months ago")
|
||||
*
|
||||
* The UI may highlight these contacts,
|
||||
* eg. draw a little green dot on the avatars of the users recently seen.
|
||||
* DC_CONTACT_ID_SELF and other special contact IDs are defined as never seen recently (they should not get a dot).
|
||||
* To get the time a contact was seen, use dc_contact_get_last_seen().
|
||||
*
|
||||
* @memberof dc_contact_t
|
||||
* @param contact The contact object.
|
||||
* @return 1=contact seen recently, 0=contact not seen recently.
|
||||
* @return One of the @ref DC_FRESHNESS constants.
|
||||
*/
|
||||
int dc_contact_was_seen_recently (const dc_contact_t* contact);
|
||||
int dc_contact_get_freshness (const dc_contact_t* contact);
|
||||
|
||||
|
||||
/**
|
||||
@@ -5908,14 +5930,6 @@ void dc_event_unref(dc_event_t* event);
|
||||
*/
|
||||
#define DC_EVENT_IMAP_MESSAGE_DELETED 104
|
||||
|
||||
/**
|
||||
* Emitted when a message was successfully moved on IMAP.
|
||||
*
|
||||
* @param data1 0
|
||||
* @param data2 (char*) Info string in English language.
|
||||
*/
|
||||
#define DC_EVENT_IMAP_MESSAGE_MOVED 105
|
||||
|
||||
/**
|
||||
* Emitted before going into IDLE on the Inbox folder.
|
||||
*
|
||||
@@ -6328,6 +6342,10 @@ void dc_event_unref(dc_event_t* event);
|
||||
* A call made while another background fetch is running gets the event immediately,
|
||||
* and the running fetch keeps emitting events until its own marker.
|
||||
*
|
||||
* The event carries no data identifying the call it belongs to,
|
||||
* so it marks your own call only if no concurrent background fetch is happening.
|
||||
* Your own call has finished when dc_accounts_background_fetch() returns.
|
||||
*
|
||||
* This event is only emitted by the account manager
|
||||
*/
|
||||
|
||||
|
||||
+19
-19
@@ -31,6 +31,7 @@ use deltachat::key::preconfigure_keypair;
|
||||
use deltachat::message::MsgId;
|
||||
use deltachat::qr_code_generator::{create_qr_svg, generate_backup_qr, get_securejoin_qr_svg};
|
||||
use deltachat::stock_str::StockMessage;
|
||||
use deltachat::transport::add_pseudo_transport;
|
||||
use deltachat::webxdc::StatusUpdateSerial;
|
||||
use deltachat::*;
|
||||
use deltachat::{accounts::Accounts, log::LogExt};
|
||||
@@ -414,6 +415,21 @@ pub unsafe extern "C" fn dc_configure(context: *mut dc_context_t) {
|
||||
spawn_configure(ctx.clone());
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dc_add_pseudo_transport(
|
||||
context: *mut dc_context_t,
|
||||
addr: *const libc::c_char,
|
||||
) {
|
||||
if context.is_null() {
|
||||
eprintln!("ignoring careless call to dc_add_pseudo_transport()");
|
||||
return;
|
||||
}
|
||||
|
||||
let ctx = unsafe { &*context };
|
||||
let addr = to_string_lossy(addr);
|
||||
block_on(add_pseudo_transport(ctx, &addr)).log_err(ctx).ok();
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dc_is_configured(context: *mut dc_context_t) -> libc::c_int {
|
||||
if context.is_null() {
|
||||
@@ -477,7 +493,6 @@ pub unsafe extern "C" fn dc_event_get_id(event: *mut dc_event_t) -> libc::c_int
|
||||
EventType::ImapConnected(_) => 102,
|
||||
EventType::SmtpMessageSent(_) => 103,
|
||||
EventType::ImapMessageDeleted(_) => 104,
|
||||
EventType::ImapMessageMoved(_) => 105,
|
||||
EventType::ImapInboxIdle => 106,
|
||||
EventType::NewBlobFile(_) => 150,
|
||||
EventType::DeletedBlobFile(_) => 151,
|
||||
@@ -544,7 +559,6 @@ pub unsafe extern "C" fn dc_event_get_data1_int(event: *mut dc_event_t) -> libc:
|
||||
| EventType::ImapConnected(_)
|
||||
| EventType::SmtpMessageSent(_)
|
||||
| EventType::ImapMessageDeleted(_)
|
||||
| EventType::ImapMessageMoved(_)
|
||||
| EventType::ImapInboxIdle
|
||||
| EventType::NewBlobFile(_)
|
||||
| EventType::DeletedBlobFile(_)
|
||||
@@ -619,7 +633,6 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
|
||||
| EventType::ImapConnected(_)
|
||||
| EventType::SmtpMessageSent(_)
|
||||
| EventType::ImapMessageDeleted(_)
|
||||
| EventType::ImapMessageMoved(_)
|
||||
| EventType::ImapInboxIdle
|
||||
| EventType::NewBlobFile(_)
|
||||
| EventType::DeletedBlobFile(_)
|
||||
@@ -714,7 +727,6 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut
|
||||
| EventType::ImapConnected(msg)
|
||||
| EventType::SmtpMessageSent(msg)
|
||||
| EventType::ImapMessageDeleted(msg)
|
||||
| EventType::ImapMessageMoved(msg)
|
||||
| EventType::NewBlobFile(msg)
|
||||
| EventType::DeletedBlobFile(msg)
|
||||
| EventType::Warning(msg)
|
||||
@@ -3963,18 +3975,6 @@ pub unsafe extern "C" fn dc_contact_get_display_name(
|
||||
ffi_contact.contact.get_display_name().strdup()
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dc_contact_get_name_n_addr(
|
||||
contact: *mut dc_contact_t,
|
||||
) -> *mut libc::c_char {
|
||||
if contact.is_null() {
|
||||
eprintln!("ignoring careless call to dc_contact_get_name_n_addr()");
|
||||
return "".strdup();
|
||||
}
|
||||
let ffi_contact = unsafe { &*contact };
|
||||
ffi_contact.contact.get_name_n_addr().strdup()
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dc_contact_get_profile_image(
|
||||
contact: *mut dc_contact_t,
|
||||
@@ -4030,13 +4030,13 @@ pub unsafe extern "C" fn dc_contact_get_last_seen(contact: *mut dc_contact_t) ->
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dc_contact_was_seen_recently(contact: *mut dc_contact_t) -> libc::c_int {
|
||||
pub unsafe extern "C" fn dc_contact_get_freshness(contact: *mut dc_contact_t) -> libc::c_int {
|
||||
if contact.is_null() {
|
||||
eprintln!("ignoring careless call to dc_contact_was_seen_recently()");
|
||||
eprintln!("ignoring careless call to dc_contact_get_freshness()");
|
||||
return 0;
|
||||
}
|
||||
let ffi_contact = unsafe { &*contact };
|
||||
ffi_contact.contact.was_seen_recently() as libc::c_int
|
||||
u32::from(ffi_contact.contact.get_freshness()) as libc::c_int
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
|
||||
@@ -47,8 +47,6 @@ impl Lot {
|
||||
Qr::AskVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
|
||||
Qr::AskJoinBroadcast { name, .. } => Some(Cow::Borrowed(name)),
|
||||
Qr::FprOk { .. } => None,
|
||||
Qr::FprMismatch { .. } => None,
|
||||
Qr::FprWithoutAddr { fingerprint, .. } => Some(Cow::Borrowed(fingerprint)),
|
||||
Qr::Account { domain } => Some(Cow::Borrowed(domain)),
|
||||
Qr::Backup2 { .. } => None,
|
||||
Qr::BackupTooNew { .. } => None,
|
||||
@@ -103,8 +101,6 @@ impl Lot {
|
||||
Qr::AskVerifyGroup { .. } => LotState::QrAskVerifyGroup,
|
||||
Qr::AskJoinBroadcast { .. } => LotState::QrAskJoinBroadcast,
|
||||
Qr::FprOk { .. } => LotState::QrFprOk,
|
||||
Qr::FprMismatch { .. } => LotState::QrFprMismatch,
|
||||
Qr::FprWithoutAddr { .. } => LotState::QrFprWithoutAddr,
|
||||
Qr::Account { .. } => LotState::QrAccount,
|
||||
Qr::Backup2 { .. } => LotState::QrBackup2,
|
||||
Qr::BackupTooNew { .. } => LotState::QrBackupTooNew,
|
||||
@@ -132,8 +128,6 @@ impl Lot {
|
||||
Qr::AskVerifyGroup { .. } => Default::default(),
|
||||
Qr::AskJoinBroadcast { .. } => Default::default(),
|
||||
Qr::FprOk { contact_id } => contact_id.to_u32(),
|
||||
Qr::FprMismatch { contact_id } => contact_id.unwrap_or_default().to_u32(),
|
||||
Qr::FprWithoutAddr { .. } => Default::default(),
|
||||
Qr::Account { .. } => Default::default(),
|
||||
Qr::Backup2 { .. } => Default::default(),
|
||||
Qr::BackupTooNew { .. } => Default::default(),
|
||||
@@ -181,12 +175,6 @@ pub enum LotState {
|
||||
/// id=contact
|
||||
QrFprOk = 210,
|
||||
|
||||
/// id=contact
|
||||
QrFprMismatch = 220,
|
||||
|
||||
/// text1=formatted fingerprint
|
||||
QrFprWithoutAddr = 230,
|
||||
|
||||
/// text1=domain
|
||||
QrAccount = 250,
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-jsonrpc-bindings"
|
||||
version = "2.61.0-dev"
|
||||
version = "2.62.0"
|
||||
description = "Autogenerate DeltaChat JSON-RPC API bindings at build time"
|
||||
edition = "2024"
|
||||
license = "MPL-2.0"
|
||||
|
||||
@@ -54,5 +54,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "dist/deltachat.d.ts",
|
||||
"version": "2.61.0-dev"
|
||||
"version": "2.62.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-jsonrpc"
|
||||
version = "2.61.0-dev"
|
||||
version = "2.62.0"
|
||||
description = "DeltaChat JSON-RPC API"
|
||||
edition = "2024"
|
||||
license = "MPL-2.0"
|
||||
|
||||
@@ -278,10 +278,26 @@ impl CommandApi {
|
||||
|
||||
/// Performs a background fetch for all accounts in parallel with a timeout.
|
||||
///
|
||||
/// For an account with IO stopped, the scheduler is paused
|
||||
/// and every transport is fetched concurrently on a dedicated connection.
|
||||
/// The account is done as soon as one transport received messages, the others stop.
|
||||
/// Only one batch of messages is fetched per transport this way,
|
||||
/// so a larger backlog is left to the next call or to started IO.
|
||||
///
|
||||
/// For an account with IO running, IMAP IDLE is interrupted on every transport
|
||||
/// and the account is done once every transport is.
|
||||
///
|
||||
/// The call never waits for outgoing messages and never triggers sending them itself.
|
||||
/// Received messages may still queue replies, securejoin handshakes for example,
|
||||
/// which go out only while IO is running.
|
||||
/// Use `is_sending_finished()` to tell whether the outgoing queue is empty.
|
||||
///
|
||||
/// The `AccountsBackgroundFetchDone` event is emitted at the end even in case of timeout,
|
||||
/// and immediately if another background fetch is already running.
|
||||
/// 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.
|
||||
/// without forgetting to create a generic notification if no message was fetched.
|
||||
/// The event carries no data identifying the call it belongs to,
|
||||
/// so it marks your own call only if no concurrent background fetch is happening.
|
||||
async fn background_fetch(&self, timeout_in_seconds: f64) -> Result<()> {
|
||||
let future = {
|
||||
let lock = self.accounts.read().await;
|
||||
@@ -292,6 +308,11 @@ impl CommandApi {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stops an ongoing `background_fetch()` call, making it return early
|
||||
/// without waiting for the remaining transports or for the timeout.
|
||||
///
|
||||
/// The `AccountsBackgroundFetchDone` event is emitted as usual.
|
||||
/// Does nothing if no background fetch is running.
|
||||
async fn stop_background_fetch(&self) -> Result<()> {
|
||||
self.accounts.read().await.stop_background_fetch();
|
||||
Ok(())
|
||||
@@ -525,6 +546,18 @@ impl CommandApi {
|
||||
ctx.add_transport_from_qr(&qr).await
|
||||
}
|
||||
|
||||
/// Adds an initial transport on the chatmail relay that answers fastest
|
||||
/// and lets the profile add further ones in the background.
|
||||
///
|
||||
/// A `DCACCOUNT:` or `DCLOGIN:` `qr` code adds a single transport
|
||||
/// while securejoin codes add the inviter's relays to the candidates.
|
||||
///
|
||||
/// Does nothing if the profile already has a transport.
|
||||
async fn init_transports(&self, account_id: u32, qr: Option<String>) -> Result<()> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
ctx.init_transports(qr.as_deref()).await
|
||||
}
|
||||
|
||||
/// Returns the list of all email accounts that are used as a transport in the current profile.
|
||||
/// Use [Self::add_or_update_transport()] to add or change a transport
|
||||
/// and [Self::delete_transport()] to remove a transport.
|
||||
|
||||
@@ -13,7 +13,6 @@ pub enum Account {
|
||||
Configured {
|
||||
id: u32,
|
||||
display_name: Option<String>,
|
||||
addr: Option<String>,
|
||||
// size: u32,
|
||||
profile_image: Option<String>,
|
||||
color: String,
|
||||
@@ -29,7 +28,6 @@ impl Account {
|
||||
pub async fn from_context(ctx: &deltachat::context::Context, id: u32) -> Result<Self> {
|
||||
if ctx.is_configured().await? {
|
||||
let display_name = ctx.get_config(Config::Displayname).await?;
|
||||
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)
|
||||
@@ -41,7 +39,6 @@ impl Account {
|
||||
Ok(Account::Configured {
|
||||
id,
|
||||
display_name,
|
||||
addr,
|
||||
profile_image,
|
||||
color,
|
||||
private_tag,
|
||||
|
||||
@@ -9,6 +9,8 @@ use deltachat::context::Context;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use typescript_type_def::TypeDef;
|
||||
|
||||
use crate::api::types::contact::ContactFreshness;
|
||||
|
||||
use super::color_int_to_hex_string;
|
||||
|
||||
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
|
||||
@@ -69,7 +71,7 @@ pub struct FullChat {
|
||||
is_muted: bool,
|
||||
ephemeral_timer: u32,
|
||||
can_send: bool,
|
||||
was_seen_recently: bool,
|
||||
freshness: ContactFreshness,
|
||||
mailing_list_address: Option<String>,
|
||||
}
|
||||
|
||||
@@ -92,16 +94,17 @@ impl FullChat {
|
||||
|
||||
let can_send = chat.can_send(context).await?;
|
||||
|
||||
let was_seen_recently = if chat.get_type() == Chattype::Single {
|
||||
let freshness = if chat.get_type() == Chattype::Single {
|
||||
match contact_ids.first() {
|
||||
Some(contact) => Contact::get_by_id(context, *contact)
|
||||
.await
|
||||
.context("failed to load contact for was_seen_recently")?
|
||||
.was_seen_recently(),
|
||||
None => false,
|
||||
.context("failed to load contact for get_freshness")?
|
||||
.get_freshness()
|
||||
.into(),
|
||||
None => ContactFreshness::Normal,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
ContactFreshness::Normal
|
||||
};
|
||||
|
||||
let mailing_list_address = chat.get_mailinglist_addr().map(|s| s.to_string());
|
||||
@@ -126,7 +129,7 @@ impl FullChat {
|
||||
is_muted: chat.is_muted(),
|
||||
ephemeral_timer,
|
||||
can_send,
|
||||
was_seen_recently,
|
||||
freshness,
|
||||
mailing_list_address,
|
||||
})
|
||||
}
|
||||
@@ -137,7 +140,7 @@ impl FullChat {
|
||||
/// - fresh_message_counter
|
||||
/// - ephemeral_timer
|
||||
/// - self_in_group
|
||||
/// - was_seen_recently
|
||||
/// - freshness
|
||||
/// - can_send
|
||||
///
|
||||
/// used when you only need the basic metadata of a chat like type, name, profile picture
|
||||
|
||||
@@ -11,6 +11,8 @@ use num_traits::cast::ToPrimitive;
|
||||
use serde::Serialize;
|
||||
use typescript_type_def::TypeDef;
|
||||
|
||||
use crate::api::types::contact::ContactFreshness;
|
||||
|
||||
use super::chat::JsonrpcChatType;
|
||||
use super::color_int_to_hex_string;
|
||||
use super::message::MessageViewtype;
|
||||
@@ -68,7 +70,7 @@ pub enum ChatListItemFetchResult {
|
||||
is_contact_request: bool,
|
||||
/// contact id if this is a dm chat (for view profile entry in context menu)
|
||||
dm_chat_contact: Option<u32>,
|
||||
was_seen_recently: bool,
|
||||
freshness: ContactFreshness,
|
||||
last_message_type: Option<MessageViewtype>,
|
||||
last_message_id: Option<u32>,
|
||||
},
|
||||
@@ -127,22 +129,20 @@ pub(crate) async fn get_chat_list_item_by_id(
|
||||
None => (None, None),
|
||||
};
|
||||
|
||||
let (dm_chat_contact, was_seen_recently) = if chat.get_type() == Chattype::Single {
|
||||
let (dm_chat_contact, freshness) = if chat.get_type() == Chattype::Single {
|
||||
let chat_contacts = get_chat_contacts(ctx, chat_id).await?;
|
||||
let contact = chat_contacts.first();
|
||||
let was_seen_recently = match contact {
|
||||
let freshness = match contact {
|
||||
Some(contact) => Contact::get_by_id(ctx, *contact)
|
||||
.await
|
||||
.context("contact")?
|
||||
.was_seen_recently(),
|
||||
None => false,
|
||||
.get_freshness()
|
||||
.into(),
|
||||
None => ContactFreshness::Normal,
|
||||
};
|
||||
(
|
||||
contact.map(|contact_id| contact_id.to_u32()),
|
||||
was_seen_recently,
|
||||
)
|
||||
(contact.map(|contact_id| contact_id.to_u32()), freshness)
|
||||
} else {
|
||||
(None, false)
|
||||
(None, ContactFreshness::Normal)
|
||||
};
|
||||
|
||||
let color = color_int_to_hex_string(chat.get_color(ctx).await?);
|
||||
@@ -170,7 +170,7 @@ pub(crate) async fn get_chat_list_item_by_id(
|
||||
is_muted: chat.is_muted(),
|
||||
is_contact_request: chat.is_contact_request(),
|
||||
dm_chat_contact,
|
||||
was_seen_recently,
|
||||
freshness,
|
||||
last_message_type: message_type,
|
||||
last_message_id: last_msgid.map(|id| id.to_u32()),
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use deltachat::contact;
|
||||
use deltachat::context::Context;
|
||||
use deltachat::key::{DcKey, SignedPublicKey};
|
||||
use serde::Serialize;
|
||||
@@ -6,6 +7,27 @@ use typescript_type_def::TypeDef;
|
||||
|
||||
use super::color_int_to_hex_string;
|
||||
|
||||
/// Freshness of a contact, based on when it was last seen.
|
||||
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
|
||||
pub enum ContactFreshness {
|
||||
/// Contact shall not be highlighted.
|
||||
Normal,
|
||||
/// Contact was seen recently.
|
||||
RecentlySeen,
|
||||
/// Contact was not seen for a long time.
|
||||
Old,
|
||||
}
|
||||
|
||||
impl From<contact::Freshness> for ContactFreshness {
|
||||
fn from(freshness: contact::Freshness) -> Self {
|
||||
match freshness {
|
||||
contact::Freshness::Normal => ContactFreshness::Normal,
|
||||
contact::Freshness::RecentlySeen => ContactFreshness::RecentlySeen,
|
||||
contact::Freshness::Old => ContactFreshness::Old,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
|
||||
#[serde(rename = "Contact", rename_all = "camelCase")]
|
||||
pub struct ContactObject {
|
||||
@@ -17,7 +39,6 @@ pub struct ContactObject {
|
||||
id: u32,
|
||||
name: String,
|
||||
profile_image: Option<String>, // BLOBS
|
||||
name_and_addr: String,
|
||||
is_blocked: bool,
|
||||
|
||||
/// Is the contact a key contact.
|
||||
@@ -33,7 +54,7 @@ pub struct ContactObject {
|
||||
|
||||
/// the contact's last seen timestamp
|
||||
last_seen: i64,
|
||||
was_seen_recently: bool,
|
||||
freshness: ContactFreshness,
|
||||
|
||||
/// If the contact is a bot.
|
||||
is_bot: bool,
|
||||
@@ -57,12 +78,11 @@ impl ContactObject {
|
||||
id: contact.id.to_u32(),
|
||||
name: contact.get_name().to_owned(),
|
||||
profile_image, //BLOBS
|
||||
name_and_addr: contact.get_name_n_addr(),
|
||||
is_blocked: contact.is_blocked(),
|
||||
is_key_contact: contact.is_key_contact(),
|
||||
e2ee_avail: contact.e2ee_avail(context).await?,
|
||||
last_seen: contact.last_seen(),
|
||||
was_seen_recently: contact.was_seen_recently(),
|
||||
freshness: contact.get_freshness().into(),
|
||||
is_bot: contact.is_bot(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,9 +44,6 @@ pub enum EventType {
|
||||
/// Emitted when an IMAP message has been marked as deleted
|
||||
ImapMessageDeleted { msg: String },
|
||||
|
||||
/// Emitted when an IMAP message has been moved
|
||||
ImapMessageMoved { msg: String },
|
||||
|
||||
/// Emitted before going into IDLE on the Inbox folder.
|
||||
ImapInboxIdle,
|
||||
|
||||
@@ -496,7 +493,6 @@ impl From<CoreEventType> for EventType {
|
||||
CoreEventType::ImapConnected(msg) => ImapConnected { msg },
|
||||
CoreEventType::SmtpMessageSent(msg) => SmtpMessageSent { msg },
|
||||
CoreEventType::ImapMessageDeleted(msg) => ImapMessageDeleted { msg },
|
||||
CoreEventType::ImapMessageMoved(msg) => ImapMessageMoved { msg },
|
||||
CoreEventType::ImapInboxIdle => ImapInboxIdle,
|
||||
CoreEventType::NewBlobFile(file) => NewBlobFile { file },
|
||||
CoreEventType::DeletedBlobFile(file) => DeletedBlobFile { file },
|
||||
|
||||
@@ -68,16 +68,6 @@ pub enum QrObject {
|
||||
/// Contact ID.
|
||||
contact_id: u32,
|
||||
},
|
||||
/// Scanned fingerprint does not match the last seen fingerprint.
|
||||
FprMismatch {
|
||||
/// Contact ID.
|
||||
contact_id: Option<u32>,
|
||||
},
|
||||
/// The scanned QR code contains a fingerprint but no e-mail address.
|
||||
FprWithoutAddr {
|
||||
/// Key fingerprint.
|
||||
fingerprint: String,
|
||||
},
|
||||
/// Ask the user if they want to create an account on the given domain.
|
||||
Account {
|
||||
/// Server domain name.
|
||||
@@ -296,11 +286,6 @@ impl From<Qr> for QrObject {
|
||||
let contact_id = contact_id.to_u32();
|
||||
QrObject::FprOk { contact_id }
|
||||
}
|
||||
Qr::FprMismatch { contact_id } => {
|
||||
let contact_id = contact_id.map(|contact_id| contact_id.to_u32());
|
||||
QrObject::FprMismatch { contact_id }
|
||||
}
|
||||
Qr::FprWithoutAddr { fingerprint } => QrObject::FprWithoutAddr { fingerprint },
|
||||
Qr::Account { domain } => QrObject::Account { domain },
|
||||
Qr::Backup2 {
|
||||
ref node_addr,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-repl"
|
||||
version = "2.61.0-dev"
|
||||
version = "2.62.0"
|
||||
license = "MPL-2.0"
|
||||
edition = "2024"
|
||||
repository = "https://github.com/chatmail/core"
|
||||
|
||||
@@ -1113,11 +1113,11 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
|
||||
|
||||
let contact_id = ContactId::new(arg1.parse()?);
|
||||
let contact = Contact::get_by_id(&context, contact_id).await?;
|
||||
let name_n_addr = contact.get_name_n_addr();
|
||||
let name = contact.get_display_name();
|
||||
let addr = contact.get_addr();
|
||||
|
||||
let mut res = format!(
|
||||
"Contact info for: {}:\nIcon: {}\n",
|
||||
name_n_addr,
|
||||
"Contact info for: {name} ({addr}):\nIcon: {}\n",
|
||||
match contact.get_profile_image(&context).await? {
|
||||
Some(image) => image.to_str().unwrap().to_string(),
|
||||
None => "NoIcon".to_string(),
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "deltachat-rpc-client"
|
||||
version = "2.61.0-dev"
|
||||
version = "2.62.0"
|
||||
license = "MPL-2.0"
|
||||
description = "Python client for Delta Chat core JSON-RPC interface"
|
||||
classifiers = [
|
||||
|
||||
@@ -139,6 +139,18 @@ class Account:
|
||||
"""Add a new transport using a QR code."""
|
||||
yield self._rpc.add_transport_from_qr.future(self.id, qr)
|
||||
|
||||
@futuremethod
|
||||
def init_transports(self, qr: Optional[str] = None):
|
||||
"""Add an initial transport on the chatmail relay that answers fastest.
|
||||
|
||||
The profile then adds further ones in the background.
|
||||
A ``DCACCOUNT:`` or ``DCLOGIN:`` ``qr`` code adds a single transport
|
||||
while securejoin codes add the inviter's relays to the candidates.
|
||||
|
||||
Does nothing if the profile already has a transport.
|
||||
"""
|
||||
yield self._rpc.init_transports.future(self.id, qr)
|
||||
|
||||
def delete_transport(self, addr: str):
|
||||
"""Delete a transport."""
|
||||
self._rpc.delete_transport(self.id, addr)
|
||||
|
||||
@@ -38,7 +38,6 @@ class EventType(str, Enum):
|
||||
IMAP_CONNECTED = "ImapConnected"
|
||||
SMTP_MESSAGE_SENT = "SmtpMessageSent"
|
||||
IMAP_MESSAGE_DELETED = "ImapMessageDeleted"
|
||||
IMAP_MESSAGE_MOVED = "ImapMessageMoved"
|
||||
IMAP_INBOX_IDLE = "ImapInboxIdle"
|
||||
NEW_BLOB_FILE = "NewBlobFile"
|
||||
DELETED_BLOB_FILE = "DeletedBlobFile"
|
||||
@@ -70,6 +69,7 @@ class EventType(str, Enum):
|
||||
SELFAVATAR_CHANGED = "SelfavatarChanged"
|
||||
WEBXDC_STATUS_UPDATE = "WebxdcStatusUpdate"
|
||||
WEBXDC_INSTANCE_DELETED = "WebxdcInstanceDeleted"
|
||||
ACCOUNTS_BACKGROUND_FETCH_DONE = "AccountsBackgroundFetchDone"
|
||||
CHATLIST_CHANGED = "ChatlistChanged"
|
||||
CHATLIST_ITEM_CHANGED = "ChatlistItemChanged"
|
||||
ACCOUNTS_CHANGED = "AccountsChanged"
|
||||
|
||||
@@ -48,6 +48,13 @@ class DeltaChat:
|
||||
"""Stop ongoing background fetch."""
|
||||
self.rpc.stop_background_fetch()
|
||||
|
||||
def wait_for_event(self, event_type=None) -> AttrDict:
|
||||
"""Wait until the next account manager event and return it."""
|
||||
while True:
|
||||
next_event = AttrDict(self.rpc.wait_for_event(0))
|
||||
if event_type is None or next_event.kind == event_type:
|
||||
return next_event
|
||||
|
||||
def maybe_network(self) -> None:
|
||||
"""Indicate that the network conditions might have changed."""
|
||||
self.rpc.maybe_network()
|
||||
|
||||
@@ -24,6 +24,12 @@ def wait_for_imap_message(imap):
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def test_init_transports(acf):
|
||||
account = acf.get_unconfigured_account()
|
||||
account.init_transports(acf.get_account_qr())
|
||||
assert len(account.list_transports()) == 1
|
||||
|
||||
|
||||
def test_add_second_address(acf) -> None:
|
||||
account = acf.new_configured_account()
|
||||
assert len(account.list_transports()) == 1
|
||||
@@ -52,10 +58,9 @@ def test_add_second_address(acf) -> None:
|
||||
|
||||
|
||||
def test_change_address(acf) -> None:
|
||||
"""Test Alice configuring a second transport and setting it as a primary one."""
|
||||
"""Test Alice configuring a second transport and removing the first one."""
|
||||
alice, bob = acf.get_online_accounts(2)
|
||||
|
||||
bob_addr = bob.get_config("configured_addr")
|
||||
bob.create_chat(alice)
|
||||
|
||||
alice_chat_bob = alice.create_chat(bob)
|
||||
@@ -65,22 +70,14 @@ def test_change_address(acf) -> None:
|
||||
sender_addr1 = msg1.sender.get_snapshot().address
|
||||
|
||||
alice.stop_io()
|
||||
old_alice_addr = alice.get_config("configured_addr")
|
||||
old_alice_addr = alice.list_transports()[0]["addr"]
|
||||
alice_vcard = alice.self_contact.make_vcard()
|
||||
assert old_alice_addr in alice_vcard
|
||||
qr = acf.get_account_qr()
|
||||
alice.add_transport_from_qr(qr)
|
||||
new_alice_addr = alice.list_transports()[1]["addr"]
|
||||
with pytest.raises(JsonRpcError):
|
||||
# Cannot use the address that is not
|
||||
# configured for any transport.
|
||||
alice.set_config("configured_addr", bob_addr)
|
||||
|
||||
# Load old address so it is cached.
|
||||
assert alice.get_config("configured_addr") == old_alice_addr
|
||||
alice.set_config("configured_addr", new_alice_addr)
|
||||
# Make sure that setting `configured_addr` invalidated the cache.
|
||||
assert alice.get_config("configured_addr") == new_alice_addr
|
||||
alice.delete_transport(old_alice_addr)
|
||||
|
||||
alice_vcard = alice.self_contact.make_vcard()
|
||||
assert old_alice_addr not in alice_vcard
|
||||
@@ -228,10 +225,6 @@ def test_transport_sync_new_as_primary(acf, log) -> None:
|
||||
|
||||
log.section("ac1 changes the primary transport")
|
||||
ac1.set_config("configured_addr", transport2["addr"])
|
||||
ac1.wait_for_event(EventType.TRANSPORTS_MODIFIED)
|
||||
|
||||
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
|
||||
assert ac1_clone.get_config("configured_addr") == transport1["addr"]
|
||||
|
||||
log.section("ac1_clone receives a message via the new transport")
|
||||
ac1_chat = ac1.create_chat(bob)
|
||||
@@ -402,3 +395,14 @@ def test_background_fetch_no_duplicates(acf, direct_imap, dc):
|
||||
|
||||
dc.background_fetch(300)
|
||||
assert len(messages_with_text(alice_chat, "hello")) == 1
|
||||
|
||||
|
||||
def test_multitransport_mdn(acf):
|
||||
"""Test sending an MDN right after configuring two transports."""
|
||||
alice, bob = acf.get_online_accounts(2)
|
||||
alice.add_transport_from_qr(acf.get_account_qr())
|
||||
alice.bring_online()
|
||||
alice.create_chat(bob)
|
||||
bob_msg = bob.create_chat(alice).send_text("Hello!")
|
||||
alice.wait_for_incoming_msg().mark_seen()
|
||||
assert bob.wait_for_event(EventType.MSG_READ).msg_id == bob_msg.id
|
||||
|
||||
@@ -146,17 +146,13 @@ def test_qr_securejoin_broadcast(acf, all_devices_online):
|
||||
assert "invited you to join this channel" in first_msg.text
|
||||
assert first_msg.is_info
|
||||
|
||||
if inviter_side:
|
||||
member_added_msg = chat_msgs.pop(0).get_snapshot()
|
||||
assert member_added_msg.text == f"Member {contact_snapshot.display_name} added."
|
||||
assert member_added_msg.info_contact_id == contact_snapshot.id
|
||||
else:
|
||||
if not inviter_side:
|
||||
if chat_msgs[0].get_snapshot().text == "You joined the channel.":
|
||||
member_added_msg = chat_msgs.pop(0).get_snapshot()
|
||||
else:
|
||||
member_added_msg = chat_msgs.pop(1).get_snapshot()
|
||||
assert member_added_msg.text == "You joined the channel."
|
||||
assert member_added_msg.is_info
|
||||
assert member_added_msg.is_info
|
||||
|
||||
hello_msg = chat_msgs.pop(0).get_snapshot()
|
||||
assert hello_msg.text == "Hello everyone!"
|
||||
@@ -221,7 +217,7 @@ def test_qr_securejoin_broadcast(acf, all_devices_online):
|
||||
snapshot = fiona.wait_for_incoming_msg().get_snapshot()
|
||||
assert snapshot.text == "You joined the channel."
|
||||
|
||||
get_broadcast(alice2).get_messages()[2].resend()
|
||||
get_broadcast(alice2).get_messages()[1].resend()
|
||||
snapshot = fiona.wait_for_incoming_msg().get_snapshot()
|
||||
assert snapshot.text == "Hello everyone!"
|
||||
|
||||
|
||||
@@ -421,7 +421,6 @@ def test_dont_move_sync_msgs(acf, direct_imap):
|
||||
addr, password = acf.get_credentials()
|
||||
ac1 = acf.get_unconfigured_account()
|
||||
ac1.set_config("bcc_self", "1")
|
||||
ac1.set_config("fix_is_chatmail", "1")
|
||||
ac1.add_or_update_transport({"addr": addr, "password": password})
|
||||
ac1.start_io()
|
||||
ac1_direct_imap = direct_imap(ac1)
|
||||
@@ -1188,7 +1187,6 @@ def test_leave_broadcast(acf, all_devices_online):
|
||||
|
||||
def check_account(ac, contact, inviter_side, please_wait_info_msg=False):
|
||||
chat = get_broadcast(ac)
|
||||
contact_snapshot = contact.get_snapshot()
|
||||
chat_msgs = chat.get_messages()
|
||||
|
||||
encrypted_msg = chat_msgs.pop(0).get_snapshot()
|
||||
@@ -1200,14 +1198,11 @@ def test_leave_broadcast(acf, all_devices_online):
|
||||
assert "invited you to join this channel" in first_msg.text
|
||||
assert first_msg.is_info
|
||||
|
||||
member_added_msg = chat_msgs.pop(0).get_snapshot()
|
||||
if inviter_side:
|
||||
assert member_added_msg.text == f"Member {contact_snapshot.display_name} added."
|
||||
else:
|
||||
assert member_added_msg.text == "You joined the channel."
|
||||
assert member_added_msg.is_info
|
||||
|
||||
if not inviter_side:
|
||||
member_added_msg = chat_msgs.pop(0).get_snapshot()
|
||||
assert member_added_msg.text == "You joined the channel."
|
||||
assert member_added_msg.is_info
|
||||
|
||||
leave_msg = chat_msgs.pop(0).get_snapshot()
|
||||
assert leave_msg.text == "You left the channel."
|
||||
|
||||
@@ -1354,6 +1349,22 @@ def test_background_fetch(acf, dc):
|
||||
break
|
||||
|
||||
|
||||
def test_background_fetch_does_not_wait_for_sending(dc, acf):
|
||||
alice, bob = acf.get_online_accounts(2)
|
||||
alice_chat_bob = alice.create_chat(bob)
|
||||
|
||||
alice.stop_io()
|
||||
text = "x" * 200_000
|
||||
for _ in range(50):
|
||||
alice_chat_bob.send_text(text)
|
||||
assert not dc.is_sending_finished()
|
||||
|
||||
alice.start_io()
|
||||
dc.background_fetch(50)
|
||||
dc.wait_for_event(EventType.ACCOUNTS_BACKGROUND_FETCH_DONE)
|
||||
assert not dc.is_sending_finished()
|
||||
|
||||
|
||||
def test_message_exists(acf):
|
||||
ac1, ac2 = acf.get_online_accounts(2)
|
||||
chat = ac1.create_chat(ac2)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-rpc-server"
|
||||
version = "2.61.0-dev"
|
||||
version = "2.62.0"
|
||||
description = "DeltaChat JSON-RPC server"
|
||||
edition = "2024"
|
||||
license = "MPL-2.0"
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "index.d.ts",
|
||||
"version": "2.61.0-dev"
|
||||
"version": "2.62.0"
|
||||
}
|
||||
|
||||
+9
-2
@@ -630,9 +630,12 @@ CREATE TABLE broadcast_secrets(
|
||||
|
||||
|
||||
-- Candidate chatmail relays for automatic relay management.
|
||||
-- Holds the hosts a QR code contributed and the default relays already tried;
|
||||
-- the default list itself lives in `autorelay.rs` and is not stored here.
|
||||
CREATE TABLE relay_candidates(
|
||||
host TEXT PRIMARY KEY NOT NULL,
|
||||
last_tried INTEGER NOT NULL DEFAULT 0 -- Timestamp of the last connection attempt.
|
||||
-- Timestamp of the last connection attempt, 0 if the host was never tried.
|
||||
last_tried INTEGER NOT NULL DEFAULT 0
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE transports (
|
||||
@@ -680,7 +683,11 @@ CREATE TABLE imap (
|
||||
transport_id INTEGER NOT NULL, -- ID of the transport in the `transports` table.
|
||||
rfc724_mid TEXT NOT NULL, -- Message-ID header
|
||||
folder TEXT NOT NULL, -- IMAP folder
|
||||
target TEXT NOT NULL, -- Destination folder. Empty string means that the message shall be deleted.
|
||||
|
||||
-- Destination folder. Empty string means that the message shall be deleted.
|
||||
-- Since we don't move messages between IMAP folders anymore,
|
||||
-- this is always either empty or equal to `folder`.
|
||||
target TEXT NOT NULL,
|
||||
uid INTEGER NOT NULL, -- UID
|
||||
uidvalidity INTEGER NOT NULL,
|
||||
UNIQUE (transport_id, folder, uid, uidvalidity)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "deltachat"
|
||||
version = "2.61.0-dev"
|
||||
version = "2.62.0"
|
||||
license = "MPL-2.0"
|
||||
description = "Python bindings for the Delta Chat Core library using CFFI against the Rust-implemented libdeltachat"
|
||||
readme = "README.rst"
|
||||
|
||||
@@ -11,6 +11,9 @@ import random
|
||||
from queue import Queue
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from .capi import lib
|
||||
from .cutil import as_dc_charpointer
|
||||
|
||||
import pytest
|
||||
from _pytest._code import Source
|
||||
|
||||
@@ -366,6 +369,7 @@ class ACFactory:
|
||||
ac.open(passphrase)
|
||||
acname = ac._logid
|
||||
addr = f"{acname}@offline.org"
|
||||
lib.dc_add_pseudo_transport(ac._dc_context, as_dc_charpointer(addr))
|
||||
ac.update_config(
|
||||
{
|
||||
"configured_addr": addr,
|
||||
@@ -374,6 +378,7 @@ class ACFactory:
|
||||
)
|
||||
self._preconfigure_key(ac)
|
||||
self._acsetup.init_logging(ac)
|
||||
assert ac.is_configured(), "Pseudo configured account should look like if it is configured"
|
||||
return ac
|
||||
|
||||
def new_online_configuring_account(self, cloned_from=None, **kwargs) -> Account:
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2026-09-11
|
||||
2026-09-22
|
||||
+5
-1
@@ -482,11 +482,15 @@ impl Accounts {
|
||||
/// return immediately even before the timeout expiration
|
||||
/// or finishing fetching.
|
||||
///
|
||||
/// Pending outgoing messages are not waited for and not triggered.
|
||||
///
|
||||
/// 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.
|
||||
/// If another background fetch is already running,
|
||||
/// nothing is fetched and the event is emitted immediately.
|
||||
/// The event carries no data identifying the call it belongs to,
|
||||
/// so it only safely refers to your call if no concurrent background fetch is happening.
|
||||
///
|
||||
/// Returns a future that resolves when background fetch is done,
|
||||
/// but does not capture `&self`.
|
||||
@@ -524,7 +528,7 @@ impl Accounts {
|
||||
pub async fn is_sending_finished(&self) -> Result<bool> {
|
||||
let accounts: Vec<Context> = self.accounts.values().cloned().collect();
|
||||
for account in accounts {
|
||||
if !smtp::is_queue_empty(&account).await? {
|
||||
if !smtp::queue::is_empty(&account).await? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
+144
-45
@@ -1,29 +1,24 @@
|
||||
//! # Automatic relay handling (experimental, still in development)
|
||||
//! # Automatic multi-relay onboarding
|
||||
//!
|
||||
//! Chatmail relays create an account on first login,
|
||||
//! so a profile can add further transports on its own without user interaction.
|
||||
//! Candidate hosts come from the `relay_candidates` table,
|
||||
//! which migrations seed with a list of known chatmail relays.
|
||||
//!
|
||||
//! Status of implementation:
|
||||
//! Additions are attempted right before going into IMAP IDLE,
|
||||
//! i.e. only while connected and with nothing more important to do,
|
||||
//! and only if a UI opted in via [`Config::Autorelay`].
|
||||
//! Once a profile has reached `NUM_TRANSPORTS_TARGET` transports,
|
||||
//! [`Config::AutorelayFinished`] is set and nothing is ever added again,
|
||||
//! so deleting a transport later does not pull in a replacement.
|
||||
//! Support for automatically onboarding a profile on transport
|
||||
//! candidates without the user choosing a relay.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Result, format_err};
|
||||
use deltachat_contact_tools::addr_normalize;
|
||||
use rand::distr::{Alphanumeric, SampleString};
|
||||
use rand::seq::IndexedRandom;
|
||||
use rand::seq::{IndexedRandom, SliceRandom};
|
||||
use rusqlite::Transaction;
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
use crate::config::{self, Config};
|
||||
use crate::configure::{EnteredLoginParam, SILENT_PROGRESS, configure};
|
||||
use crate::log::{LogExt, warn};
|
||||
use crate::login_param::{EnteredCertificateChecks, EnteredImapLoginParam};
|
||||
use crate::{configure::EnteredLoginParam, context::Context, tools::time};
|
||||
use crate::net::{connect_tcp, proxy::ProxyConfig};
|
||||
use crate::{context::Context, tools::time};
|
||||
|
||||
/// The target number of transports.
|
||||
const NUM_TRANSPORTS_TARGET: usize = 3;
|
||||
@@ -32,6 +27,87 @@ const AUTOMATIC_ADDITION_DEBOUNCE_SECONDS: i64 = 60 * 60; // one hour
|
||||
/// How long we ignore a relay candidate after failing to connect to it:
|
||||
const BACKOFF_PERIOD_FOR_NOT_WORKING_RELAY: i64 = 60 * 60 * 24 * 7; // one week
|
||||
|
||||
/// Sorted relay list a profile can attempt to onboard on without the user choosing one.
|
||||
const DEFAULT_RELAY_CANDIDATES: &[&str] = &[
|
||||
"chat.adminforge.de", // iroh relay 404s
|
||||
"chat.me.ke",
|
||||
"chat.nuvon.app",
|
||||
"chat.tinydispatch.org",
|
||||
"chat.vim.wtf",
|
||||
"chatmail.uk",
|
||||
"chtml.ca",
|
||||
"deltachat.me",
|
||||
"e2e.sus.fr",
|
||||
"e2ee.wang",
|
||||
"mailchat.pl",
|
||||
"nchrcht.la10cy.net",
|
||||
"nine.testrun.org",
|
||||
"sweetfern.net",
|
||||
"tarpit.fun",
|
||||
];
|
||||
|
||||
/// Records the hosts of `addrs` as relay candidates.
|
||||
pub(crate) async fn add_relay_candidates(context: &Context, addrs: &[String]) -> Result<()> {
|
||||
context
|
||||
.sql
|
||||
.transaction(|tx| hosts_of(addrs).try_for_each(|host| save_relay_candidate(tx, host, 0)))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Adds a first transport on the relay candidate that answers fastest.
|
||||
///
|
||||
/// All candidates are probed at once with a TCP connection to their HTTPS port
|
||||
/// and configured in the order in which the connections complete,
|
||||
/// stopping at the first success. Candidates that fail the probe are skipped.
|
||||
/// Answering TCP fastest is used as a network proximity measure,
|
||||
/// which keeps latency low for initial onboarding,
|
||||
/// and it avoids relays that are down or black-holing traffic.
|
||||
pub(crate) async fn add_transport_from_candidates(
|
||||
context: &Context,
|
||||
skip_network: bool,
|
||||
) -> Result<()> {
|
||||
let mut candidates = triable_relay_candidates(context, time()).await?;
|
||||
candidates.shuffle(&mut rand::rng());
|
||||
let mut probes = JoinSet::new();
|
||||
let proxy_config = ProxyConfig::load(context).await?;
|
||||
let load_cache = false;
|
||||
for host in candidates {
|
||||
let ctx = context.clone();
|
||||
let proxy_config = proxy_config.clone();
|
||||
probes.spawn(async move {
|
||||
let res = match proxy_config {
|
||||
_ if skip_network => Ok(()),
|
||||
Some(proxy) => proxy.connect(&ctx, &host, 443, load_cache).await.map(drop),
|
||||
None => connect_tcp(&ctx, &host, 443, load_cache).await.map(drop),
|
||||
};
|
||||
(host, res)
|
||||
});
|
||||
}
|
||||
|
||||
let mut last_err = format_err!("No relay candidates");
|
||||
let mark_as_autorelay = true;
|
||||
while let Some(res) = probes.join_next().await {
|
||||
let (host, res) = res?;
|
||||
if let Err(err) = res {
|
||||
warn!(context, "Failed to connect to relay {host}: {err:#}.");
|
||||
last_err = err;
|
||||
continue;
|
||||
}
|
||||
let param = login_param_from_host(&host, mark_as_autorelay);
|
||||
match configure(context, ¶m, skip_network).await {
|
||||
Ok(()) => {
|
||||
info!(context, "Added a transport on relay {host}.");
|
||||
return Ok(());
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(context, "Failed to add relay {host}: {err:#}.");
|
||||
last_err = err;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err)
|
||||
}
|
||||
|
||||
pub(crate) fn maybe_add_additional_relays(
|
||||
context: Context,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||
@@ -93,10 +169,7 @@ async fn maybe_add_additional_relays_inner(context: &Context, skip_network: bool
|
||||
return Ok(relay_added);
|
||||
}
|
||||
|
||||
// First, query all candidates that were not tried since `BACKOFF_PERIOD_FOR_NOT_WORKING_RELAY` seconds.
|
||||
// Hosts that are already used are excluded.
|
||||
let candidates = load_relay_candidates(context, now).await?;
|
||||
|
||||
let candidates = triable_relay_candidates(context, now).await?;
|
||||
let Some(host) = candidates.choose(&mut rand::rng()) else {
|
||||
info!(
|
||||
context,
|
||||
@@ -113,13 +186,13 @@ async fn maybe_add_additional_relays_inner(context: &Context, skip_network: bool
|
||||
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"UPDATE relay_candidates SET last_tried=? WHERE host=?",
|
||||
(now, host),
|
||||
)
|
||||
.transaction(|tx| save_relay_candidate(tx, host, now))
|
||||
.await?;
|
||||
let param = login_param_from_host(host);
|
||||
let res = crate::configure::configure(context, ¶m, skip_network).await;
|
||||
let mark_as_autorelay = true;
|
||||
let param = login_param_from_host(host, mark_as_autorelay);
|
||||
let res = SILENT_PROGRESS
|
||||
.scope((), configure(context, ¶m, skip_network))
|
||||
.await;
|
||||
if let Err(e) = res {
|
||||
warn!(
|
||||
context,
|
||||
@@ -134,37 +207,63 @@ async fn maybe_add_additional_relays_inner(context: &Context, skip_network: bool
|
||||
Ok(relay_added)
|
||||
}
|
||||
|
||||
async fn load_relay_candidates(context: &Context, now: i64) -> Result<Vec<String>, anyhow::Error> {
|
||||
async fn triable_relay_candidates(context: &Context, now: i64) -> Result<Vec<String>> {
|
||||
let cutoff_timestamp = now.saturating_sub(BACKOFF_PERIOD_FOR_NOT_WORKING_RELAY);
|
||||
let candidates: Vec<String> = context
|
||||
let mut last_tried: BTreeMap<String, i64> = context
|
||||
.sql
|
||||
.query_map_vec(
|
||||
// This also selects candidates which have last_tried in the future,
|
||||
// essentially treating them as never tried,
|
||||
// so if some timestamp far in the future is accidentally stored,
|
||||
// we are not stuck never trying the candidate.
|
||||
// After trying the candidate, last_tried will be corrected to the current time.
|
||||
"SELECT host FROM relay_candidates WHERE (last_tried<? OR last_tried>?)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM transports
|
||||
WHERE substr(addr, instr(addr, '@') + 1) = host
|
||||
)",
|
||||
(cutoff_timestamp, now),
|
||||
|row| Ok(row.get::<_, String>(0)?),
|
||||
)
|
||||
.query_map_collect("SELECT host, last_tried FROM relay_candidates", (), |row| {
|
||||
Ok((row.get(0)?, row.get(1)?))
|
||||
})
|
||||
.await?;
|
||||
for host in DEFAULT_RELAY_CANDIDATES {
|
||||
last_tried.entry(host.to_string()).or_insert(0);
|
||||
}
|
||||
let self_addrs = context.get_self_addrs().await?;
|
||||
let used_hosts: Vec<&str> = hosts_of(&self_addrs).collect();
|
||||
|
||||
// We also try candidates which have `last_tried` in the future,
|
||||
// which on next failure get `last_tried` reset to the current time.
|
||||
let candidates = last_tried
|
||||
.into_iter()
|
||||
.filter(|(host, last_tried)| {
|
||||
(*last_tried < cutoff_timestamp || *last_tried > now)
|
||||
&& !used_hosts.contains(&host.as_str())
|
||||
})
|
||||
.map(|(host, _)| host)
|
||||
.collect();
|
||||
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
pub(crate) fn login_param_from_host(host: &str) -> EnteredLoginParam {
|
||||
/// Returns the host of each address in `addrs`.
|
||||
fn hosts_of(addrs: &[String]) -> impl Iterator<Item = &str> {
|
||||
addrs.iter().filter_map(|a| Some(a.rsplit_once('@')?.1))
|
||||
}
|
||||
|
||||
/// Records `host` as a relay candidate, overwriting a stored `last_tried`.
|
||||
fn save_relay_candidate(tx: &Transaction, host: &str, last_tried: i64) -> Result<()> {
|
||||
tx.execute(
|
||||
"INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)
|
||||
ON CONFLICT(host) DO UPDATE SET last_tried=excluded.last_tried",
|
||||
(host, last_tried),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn login_param_from_host(host: &str, mark_as_autorelay: bool) -> EnteredLoginParam {
|
||||
let rng = &mut rand::rng();
|
||||
let username = Alphanumeric.sample_string(rng, 9);
|
||||
let addr = username + "@" + host;
|
||||
let addr = addr_normalize(&addr);
|
||||
|
||||
// `mark_as_autorelay` is a temporary precaution hack
|
||||
// while introducing onboarding on multiple community relays from a list:
|
||||
// though relay operators were asked to get on that list, unexpected things can happen,
|
||||
// and they want to return to allow only manual onboarding.
|
||||
// this is possible by failing on `password_len == 23`.
|
||||
|
||||
// 22 * log2(26 * 2 + 10) = 130 bits of entropy
|
||||
let password = Alphanumeric.sample_string(rng, 22);
|
||||
let password = Alphanumeric.sample_string(rng, if mark_as_autorelay { 23 } else { 22 });
|
||||
|
||||
EnteredLoginParam {
|
||||
addr,
|
||||
|
||||
@@ -1,42 +1,150 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use super::*;
|
||||
use crate::test_utils::TestContext;
|
||||
use crate::EventType;
|
||||
use crate::test_utils::{TestContext, TestContextManager};
|
||||
use crate::tools::SystemTime;
|
||||
|
||||
/// Tests that the default relays are candidates without a row in the table.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_load_relay_candidates_single() -> Result<()> {
|
||||
async fn test_triable_relay_candidates_defaults() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let t = &tcm.unconfigured().await;
|
||||
let now = time();
|
||||
|
||||
assert!(DEFAULT_RELAY_CANDIDATES.is_sorted());
|
||||
let mut candidates = triable_relay_candidates(t, now).await?;
|
||||
candidates.sort();
|
||||
assert_eq!(candidates, DEFAULT_RELAY_CANDIDATES);
|
||||
|
||||
let tried = DEFAULT_RELAY_CANDIDATES[0];
|
||||
save_relay_candidates(t, &[tried], now).await?;
|
||||
let candidates = triable_relay_candidates(t, now).await?;
|
||||
assert_eq!(candidates.len(), DEFAULT_RELAY_CANDIDATES.len() - 1);
|
||||
assert!(!candidates.contains(&tried.to_string()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that a transport is added on a candidate from the given addresses.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_add_transport_from_candidates() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let t = &tcm.unconfigured().await;
|
||||
mark_defaults_tried(t, time()).await?;
|
||||
let addrs_from_qr = [
|
||||
"alice@example.org".to_string(),
|
||||
"bob@example.org".to_string(),
|
||||
];
|
||||
let skip_network = true;
|
||||
add_relay_candidates(t, &addrs_from_qr).await?;
|
||||
add_transport_from_candidates(t, skip_network).await?;
|
||||
|
||||
let transports = t.list_transports().await?;
|
||||
assert_eq!(transports.len(), 1);
|
||||
assert!(transports[0].addr.ends_with("@example.org"));
|
||||
let untried = untried_relay_candidates(t).await?;
|
||||
assert_eq!(untried, ["example.org"]);
|
||||
assert!(configure_progress_emitted(t).await);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests correct add_transport_from_candidates error handling.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_add_transport_from_candidates_failure() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let t = &tcm.unconfigured().await;
|
||||
mark_defaults_tried(t, time()).await?;
|
||||
save_relay_candidates(t, &["bad host", "worse host"], 0).await?;
|
||||
|
||||
let skip_network = true;
|
||||
let err = add_transport_from_candidates(t, skip_network)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(format!("{err:#}").contains("Bad email-address"));
|
||||
assert!(!t.is_configured().await?);
|
||||
let untried = untried_relay_candidates(t).await?;
|
||||
assert_eq!(untried, ["bad host", "worse host"]);
|
||||
t.assert_warns_or_errors(&[
|
||||
"Failed to add relay bad host",
|
||||
"Failed to add relay worse host",
|
||||
])
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn untried_relay_candidates(t: &TestContext) -> Result<Vec<String>> {
|
||||
t.sql
|
||||
.query_map_vec(
|
||||
"SELECT host FROM relay_candidates WHERE last_tried=0 ORDER BY host",
|
||||
(),
|
||||
|row| Ok(row.get(0)?),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn save_relay_candidates(t: &TestContext, hosts: &[&str], last_tried: i64) -> Result<()> {
|
||||
t.sql
|
||||
.transaction(|tx| {
|
||||
for host in hosts {
|
||||
save_relay_candidate(tx, host, last_tried)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Keeps the default relays out of `triable_relay_candidates()`.
|
||||
async fn mark_defaults_tried(t: &TestContext, now: i64) -> Result<()> {
|
||||
save_relay_candidates(t, DEFAULT_RELAY_CANDIDATES, now).await
|
||||
}
|
||||
|
||||
/// Consumes emitted events, telling whether a configure progress is among them.
|
||||
async fn configure_progress_emitted(t: &TestContext) -> bool {
|
||||
t.evtracker
|
||||
.get_matching_opt(t, |evt| matches!(evt, EventType::ConfigureProgress { .. }))
|
||||
.await
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Tests that saving a candidate overwrites its stored timestamp.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_save_relay_candidate() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let t = &tcm.unconfigured().await;
|
||||
let now = time();
|
||||
|
||||
for last_tried in [0, now, 0] {
|
||||
t.sql
|
||||
.transaction(|tx| save_relay_candidate(tx, "relay.example", last_tried))
|
||||
.await?;
|
||||
let stored: Option<i64> = t
|
||||
.sql
|
||||
.query_get_value(
|
||||
"SELECT last_tried FROM relay_candidates WHERE host=?",
|
||||
("relay.example",),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(stored, Some(last_tried));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_triable_relay_candidates_single() -> Result<()> {
|
||||
let t = &TestContext::new_alice().await;
|
||||
enable_config(t).await;
|
||||
let now = time();
|
||||
|
||||
t.sql.execute("DELETE FROM relay_candidates", ()).await?;
|
||||
mark_defaults_tried(t, now).await?;
|
||||
|
||||
// This host should be returned by load_relay_candidates():
|
||||
t.sql
|
||||
.execute(
|
||||
"INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)",
|
||||
("never_tried.example", 0),
|
||||
)
|
||||
.await?;
|
||||
save_relay_candidates(t, &["never_tried.example", "example.org"], 0).await?;
|
||||
save_relay_candidates(t, &["recent.example"], now).await?;
|
||||
|
||||
// This host was recently tried and should not be returned:
|
||||
t.sql
|
||||
.execute(
|
||||
"INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)",
|
||||
("recent.example", now),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// This host is already in use (alice@example.org) and should not be returned:
|
||||
t.sql
|
||||
.execute(
|
||||
"INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)",
|
||||
("example.org", 0),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let candidates = load_relay_candidates(t, now).await?;
|
||||
let candidates = triable_relay_candidates(t, now).await?;
|
||||
|
||||
assert_eq!(candidates, vec!["never_tried.example".to_string()]);
|
||||
|
||||
@@ -44,22 +152,15 @@ async fn test_load_relay_candidates_single() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_load_relay_candidates_multiple() -> Result<()> {
|
||||
async fn test_triable_relay_candidates_multiple() -> Result<()> {
|
||||
let t = &TestContext::new().await;
|
||||
enable_config(t).await;
|
||||
let now = time();
|
||||
|
||||
t.sql.execute("DELETE FROM relay_candidates", ()).await?;
|
||||
for host in ["a.example", "b.example", "c.example"] {
|
||||
t.sql
|
||||
.execute(
|
||||
"INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)",
|
||||
(host, 0),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
mark_defaults_tried(t, now).await?;
|
||||
save_relay_candidates(t, &["a.example", "b.example", "c.example"], 0).await?;
|
||||
|
||||
let mut candidates = load_relay_candidates(t, now).await?;
|
||||
let mut candidates = triable_relay_candidates(t, now).await?;
|
||||
candidates.sort();
|
||||
|
||||
assert_eq!(
|
||||
@@ -160,13 +261,8 @@ async fn test_maybe_add_additional_relays_add_one() -> Result<()> {
|
||||
enable_config(t).await;
|
||||
let now = time();
|
||||
|
||||
t.sql.execute("DELETE FROM relay_candidates", ()).await?;
|
||||
t.sql
|
||||
.execute(
|
||||
"INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)",
|
||||
("relay.example", 0),
|
||||
)
|
||||
.await?;
|
||||
mark_defaults_tried(t, now).await?;
|
||||
save_relay_candidates(t, &["relay.example"], 0).await?;
|
||||
|
||||
let transports_before = t.count_transports().await?;
|
||||
|
||||
@@ -179,6 +275,7 @@ async fn test_maybe_add_additional_relays_add_one() -> Result<()> {
|
||||
|
||||
let transports_after = t.count_transports().await?;
|
||||
assert_eq!(transports_after, transports_before + 1);
|
||||
assert!(!configure_progress_emitted(t).await);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -189,15 +286,8 @@ async fn test_maybe_add_additional_relays_add_multiple() -> Result<()> {
|
||||
enable_config(t).await;
|
||||
let now = time();
|
||||
|
||||
t.sql.execute("DELETE FROM relay_candidates", ()).await?;
|
||||
for host in ["a.example", "b.example", "c.example", "d.example"] {
|
||||
t.sql
|
||||
.execute(
|
||||
"INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)",
|
||||
(host, 0),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
mark_defaults_tried(t, now).await?;
|
||||
save_relay_candidates(t, &["a.example", "b.example", "c.example", "d.example"], 0).await?;
|
||||
|
||||
let skip_network = true;
|
||||
let relay_added = maybe_add_additional_relays_inner(t, skip_network).await?;
|
||||
@@ -218,14 +308,9 @@ async fn test_maybe_add_additional_relays_failure() -> Result<()> {
|
||||
enable_config(t).await;
|
||||
let now = time();
|
||||
|
||||
t.sql.execute("DELETE FROM relay_candidates", ()).await?;
|
||||
mark_defaults_tried(t, now).await?;
|
||||
for i in 1..10 {
|
||||
t.sql
|
||||
.execute(
|
||||
"INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)",
|
||||
(format!("{i}.invalid.example"), 0),
|
||||
)
|
||||
.await?;
|
||||
save_relay_candidates(t, &[format!("{i}.invalid.example").as_str()], 0).await?;
|
||||
}
|
||||
|
||||
let transports_before = t.count_transports().await?;
|
||||
@@ -254,7 +339,7 @@ async fn test_maybe_add_additional_relays_failure() -> Result<()> {
|
||||
|
||||
// ...but not all, because there might be many relay candidates
|
||||
// and we don't want to try all of them in a single call:
|
||||
assert_eq!(load_relay_candidates(t, now).await?.is_empty(), false);
|
||||
assert_eq!(triable_relay_candidates(t, now).await?.is_empty(), false);
|
||||
|
||||
t.assert_warns_or_errors(&[
|
||||
"DNS lookup with memory cache failure",
|
||||
|
||||
+13
-46
@@ -42,7 +42,7 @@ const RINGING_SECONDS: i64 = 120;
|
||||
const CALL_ACCEPTED_TIMESTAMP: Param = Param::Arg;
|
||||
const CALL_ENDED_TIMESTAMP: Param = Param::Arg4;
|
||||
|
||||
const STUN_PORT: u16 = 3478;
|
||||
const TURN_PORT: u16 = 3478;
|
||||
|
||||
/// Set if incoming call was ended explicitly
|
||||
/// by the other side before we accepted it.
|
||||
@@ -658,12 +658,9 @@ pub(crate) async fn create_ice_servers_from_metadata(
|
||||
Ok((expiration_timestamp, ice_servers))
|
||||
}
|
||||
|
||||
/// STUN or TURN server with unresolved DNS name.
|
||||
/// TURN server with unresolved DNS name.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(crate) enum UnresolvedIceServer {
|
||||
/// STUN server.
|
||||
Stun { hostname: String, port: u16 },
|
||||
|
||||
/// TURN server with the username and password.
|
||||
Turn {
|
||||
hostname: String,
|
||||
@@ -688,28 +685,6 @@ pub(crate) async fn resolve_ice_servers(
|
||||
|
||||
for unresolved_ice_server in unresolved_ice_servers {
|
||||
match unresolved_ice_server {
|
||||
UnresolvedIceServer::Stun { hostname, port } => {
|
||||
match lookup_host_with_cache(context, &hostname, port, "", load_cache).await {
|
||||
Ok(addrs) => {
|
||||
let urls: Vec<String> = addrs
|
||||
.into_iter()
|
||||
.map(|addr| format!("stun:{addr}"))
|
||||
.collect();
|
||||
let stun_server = IceServer {
|
||||
urls,
|
||||
username: None,
|
||||
credential: None,
|
||||
};
|
||||
result.push(stun_server);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
context,
|
||||
"Failed to resolve STUN {hostname}:{port}: {err:#}."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
UnresolvedIceServer::Turn {
|
||||
hostname,
|
||||
port,
|
||||
@@ -742,25 +717,17 @@ pub(crate) async fn resolve_ice_servers(
|
||||
|
||||
/// Creates JSON with ICE servers when no TURN servers are known.
|
||||
pub(crate) fn create_fallback_ice_servers() -> Vec<UnresolvedIceServer> {
|
||||
// Do not use public STUN server from https://stunprotocol.org/.
|
||||
// It changes the hostname every year
|
||||
// (e.g. stunserver2025.stunprotocol.org
|
||||
// which was previously stunserver2024.stunprotocol.org)
|
||||
// because of bandwidth costs:
|
||||
// <https://github.com/jselbie/stunserver/issues/50>
|
||||
|
||||
vec![
|
||||
UnresolvedIceServer::Stun {
|
||||
hostname: "nine.testrun.org".to_string(),
|
||||
port: STUN_PORT,
|
||||
},
|
||||
UnresolvedIceServer::Turn {
|
||||
hostname: "turn.delta.chat".to_string(),
|
||||
port: STUN_PORT,
|
||||
username: "public".to_string(),
|
||||
credential: "o4tR7yG4rG2slhXqRUf9zgmHz".to_string(),
|
||||
},
|
||||
]
|
||||
// As long as we can't rely on most chat profiles
|
||||
// having a multi-relay setup and offering TURN servers,
|
||||
// fall back to a service run by Delta Chat developers
|
||||
// which ensures that no IP addresses or other metadata is
|
||||
// persistently logged.
|
||||
vec![UnresolvedIceServer::Turn {
|
||||
hostname: "turn.delta.chat".to_string(),
|
||||
port: TURN_PORT,
|
||||
username: "public".to_string(),
|
||||
credential: "o4tR7yG4rG2slhXqRUf9zgmHz".to_string(),
|
||||
}]
|
||||
}
|
||||
|
||||
/// Returns JSON with ICE servers.
|
||||
|
||||
@@ -779,3 +779,13 @@ async fn test_end_text_call() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that fallback ice servers just carry the Turn one
|
||||
#[test]
|
||||
fn test_fallback_ice_servers() {
|
||||
let hostnames: Vec<String> = create_fallback_ice_servers()
|
||||
.into_iter()
|
||||
.map(|UnresolvedIceServer::Turn { hostname, .. }| hostname)
|
||||
.collect();
|
||||
assert_eq!(hostnames, ["turn.delta.chat"]);
|
||||
}
|
||||
|
||||
+13
-128
@@ -30,17 +30,17 @@ use crate::download::{DownloadState, PRE_MSG_ATTACHMENT_SIZE_THRESHOLD};
|
||||
use crate::ensure_and_debug_assert_eq;
|
||||
use crate::ephemeral::{Timer as EphemeralTimer, start_chat_ephemeral_timers};
|
||||
use crate::events::EventType;
|
||||
use crate::key::{DcKey as _, Fingerprint, self_fingerprint};
|
||||
use crate::key::{Fingerprint, self_fingerprint};
|
||||
use crate::log::{LogExt, warn};
|
||||
use crate::logged_debug_assert;
|
||||
use crate::message::{self, Message, MessageState, MsgId, Viewtype};
|
||||
use crate::mimefactory;
|
||||
use crate::mimefactory::{MimeFactory, QueueSideEffects, QueuedMail, ToBeQueuedMail};
|
||||
use crate::mimefactory::MimeFactory;
|
||||
use crate::mimeparser::SystemMessage;
|
||||
use crate::param::{Param, Params};
|
||||
use crate::pgp::addresses_from_public_key;
|
||||
use crate::reaction::broadcast_reactions;
|
||||
use crate::receive_imf::ReceivedMsg;
|
||||
use crate::smtp::queue::{ToBeQueuedMail, enqueue_mail};
|
||||
use crate::smtp::send_msg_to_smtp;
|
||||
use crate::stock_str;
|
||||
use crate::sync::{self, Sync::*, SyncData};
|
||||
@@ -331,7 +331,7 @@ impl ChatId {
|
||||
Ok(chat_id)
|
||||
}
|
||||
|
||||
fn set_selfavatar_timestamp(
|
||||
pub(crate) fn set_selfavatar_timestamp(
|
||||
self,
|
||||
transaction: &mut rusqlite::Transaction<'_>,
|
||||
timestamp: i64,
|
||||
@@ -2815,99 +2815,6 @@ async fn render_mime_message_and_pre_message(
|
||||
}
|
||||
}
|
||||
|
||||
/// Process side effects and store queued mail.
|
||||
pub(crate) fn enqueue_mail(
|
||||
transaction: &mut rusqlite::Transaction<'_>,
|
||||
now: i64,
|
||||
msg_id: MsgId,
|
||||
queued_mail: &QueuedMail,
|
||||
side_effects: Option<&QueueSideEffects>,
|
||||
) -> Result<i64> {
|
||||
if let Some(side_effects) = side_effects {
|
||||
if let Some(last_added_location_timestamp) = side_effects.last_added_location_timestamp {
|
||||
transaction.execute(
|
||||
"UPDATE chats SET locations_last_sent=? WHERE id=?;",
|
||||
(last_added_location_timestamp, side_effects.chat_id),
|
||||
)?;
|
||||
}
|
||||
|
||||
if side_effects.avatar_is_attached {
|
||||
side_effects
|
||||
.chat_id
|
||||
.set_selfavatar_timestamp(transaction, now)
|
||||
.context("Failed to set selfavatar timestamp")?;
|
||||
}
|
||||
|
||||
if let Some(ref sync_ids) = side_effects.sync_ids_to_delete {
|
||||
transaction.execute(
|
||||
&format!("DELETE FROM multi_device_sync WHERE id IN ({sync_ids})"),
|
||||
(),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Store mail into queue.
|
||||
let all_recipients = queued_mail.recipients.join(" ");
|
||||
let is_encrypted = queued_mail.encryption.is_encrypted();
|
||||
|
||||
transaction
|
||||
.execute(
|
||||
"
|
||||
INSERT INTO smtp2 (
|
||||
display_name,
|
||||
rfc724_mid,
|
||||
mime,
|
||||
should_attach_pubkey,
|
||||
should_compress,
|
||||
should_sign,
|
||||
msg_id,
|
||||
recipients,
|
||||
bcc_self,
|
||||
is_encrypted,
|
||||
shared_secret,
|
||||
encryption_fingerprints
|
||||
)
|
||||
VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
",
|
||||
(
|
||||
&queued_mail.display_name,
|
||||
&queued_mail.rfc724_mid,
|
||||
&queued_mail.raw_message,
|
||||
queued_mail.should_attach_pubkey,
|
||||
queued_mail.should_compress,
|
||||
queued_mail.should_sign,
|
||||
msg_id,
|
||||
&all_recipients,
|
||||
queued_mail.bcc_self,
|
||||
is_encrypted,
|
||||
if let mimefactory::QueuedEncryption::Symmetric { ref shared_secret } =
|
||||
queued_mail.encryption
|
||||
{
|
||||
shared_secret
|
||||
} else {
|
||||
""
|
||||
},
|
||||
if let mimefactory::QueuedEncryption::Asymmetric {
|
||||
ref encryption_pubkeys,
|
||||
} = queued_mail.encryption
|
||||
{
|
||||
let res: Vec<String> = encryption_pubkeys
|
||||
.iter()
|
||||
.map(|pubkey| pubkey.dc_fingerprint().hex())
|
||||
.collect();
|
||||
res.join(" ")
|
||||
} else {
|
||||
"".to_string()
|
||||
},
|
||||
),
|
||||
)
|
||||
.context("Failed to insert a row into smtp2 table")?;
|
||||
let row_id = transaction.last_insert_rowid();
|
||||
Ok(row_id)
|
||||
}
|
||||
|
||||
/// Constructs jobs for sending a message and inserts them into the `smtp` table.
|
||||
///
|
||||
/// Updates the message `GuaranteeE2ee` parameter and persists it
|
||||
@@ -4011,15 +3918,7 @@ pub(crate) async fn add_contact_to_chat_ext(
|
||||
msg.viewtype = Viewtype::Text;
|
||||
|
||||
let contact_addr = contact.get_addr().to_lowercase();
|
||||
let added_by = if from_handshake && chat.typ == Chattype::OutBroadcast {
|
||||
// The contact was added via a QR code rather than explicit user action,
|
||||
// so it could be confusing to say 'You added member Alice'.
|
||||
// And in a broadcast, SELF is the only one who can add members,
|
||||
// so, no information is lost by just writing 'Member Alice added' instead.
|
||||
ContactId::UNDEFINED
|
||||
} else {
|
||||
ContactId::SELF
|
||||
};
|
||||
let added_by = ContactId::SELF;
|
||||
msg.text = stock_str::msg_add_member_local(context, contact.id, added_by).await;
|
||||
msg.param.set_cmd(SystemMessage::MemberAddedToGroup);
|
||||
msg.param.set(Param::Arg, contact_addr);
|
||||
@@ -4033,6 +3932,11 @@ pub(crate) async fn add_contact_to_chat_ext(
|
||||
.await?
|
||||
.context("Failed to find broadcast shared secret")?;
|
||||
msg.param.set(PARAM_BROADCAST_SECRET, secret);
|
||||
|
||||
// We don't show "member added" info-messages in channels,
|
||||
// because there can be a lot members added,
|
||||
// and these messages would clutter the timeline.
|
||||
msg.hidden = true;
|
||||
}
|
||||
send_msg(context, chat_id, &mut msg).await?;
|
||||
|
||||
@@ -5200,7 +5104,7 @@ async fn set_contacts_by_fingerprints(
|
||||
if contacts == contacts_old {
|
||||
return Ok(());
|
||||
}
|
||||
let broadcast_contacts_added = context
|
||||
context
|
||||
.sql
|
||||
.transaction(move |transaction| {
|
||||
// For broadcast channels, we only add members,
|
||||
@@ -5217,31 +5121,12 @@ async fn set_contacts_by_fingerprints(
|
||||
let mut statement = transaction.prepare(
|
||||
"INSERT OR IGNORE INTO chats_contacts (chat_id, contact_id) VALUES (?, ?)",
|
||||
)?;
|
||||
let mut broadcast_contacts_added = Vec::new();
|
||||
for contact_id in &contacts {
|
||||
if statement.execute((id, contact_id))? > 0 && chat.typ == Chattype::OutBroadcast {
|
||||
broadcast_contacts_added.push(*contact_id);
|
||||
}
|
||||
statement.execute((id, contact_id))?;
|
||||
}
|
||||
Ok(broadcast_contacts_added)
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
let timestamp = time();
|
||||
for added_id in broadcast_contacts_added {
|
||||
let msg = stock_str::msg_add_member_local(context, added_id, ContactId::UNDEFINED).await;
|
||||
add_info_msg_with_cmd(
|
||||
context,
|
||||
id,
|
||||
&msg,
|
||||
SystemMessage::MemberAddedToGroup,
|
||||
Some(timestamp),
|
||||
timestamp,
|
||||
None,
|
||||
Some(ContactId::SELF),
|
||||
Some(added_id),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
context.emit_event(EventType::ChatModified(id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+15
-29
@@ -3003,13 +3003,13 @@ async fn test_broadcast_change_name() -> Result<()> {
|
||||
|
||||
tcm.section("Bob receives the name-change system message");
|
||||
let msg = bob.recv_msg(&sent).await;
|
||||
assert_eq!(msg.subject, "Re: My great broadcast");
|
||||
assert_eq!(msg.subject, "My great broadcast");
|
||||
let bob_chat = Chat::load_from_db(bob, msg.chat_id).await?;
|
||||
assert_eq!(bob_chat.name, "My great broadcast");
|
||||
|
||||
tcm.section("Fiona receives the name-change system message");
|
||||
let msg = fiona.recv_msg(&sent).await;
|
||||
assert_eq!(msg.subject, "Re: My great broadcast");
|
||||
assert_eq!(msg.subject, "My great broadcast");
|
||||
let fiona_chat = Chat::load_from_db(fiona, msg.chat_id).await?;
|
||||
assert_eq!(fiona_chat.name, "My great broadcast");
|
||||
}
|
||||
@@ -3343,41 +3343,27 @@ async fn test_broadcast_recipients_sync1() -> Result<()> {
|
||||
alice2.assert_warn("unknown grpid").await;
|
||||
|
||||
let member_added = alice1.pop_sent_msg().await;
|
||||
let a2_charlie_added = alice2.recv_msg(&member_added).await;
|
||||
alice2.recv_msg_trash(&member_added).await;
|
||||
let _c_member_added = charlie.recv_msg(&member_added).await;
|
||||
let a2_chatlist = Chatlist::try_load(alice2, 0, Some("Channel"), None).await?;
|
||||
assert_eq!(a2_chatlist.get_msg_id(0)?.unwrap(), a2_charlie_added.id);
|
||||
|
||||
// Alice1 will now sync the full member list to Alice2:
|
||||
sync(alice1, alice2).await;
|
||||
let a2_bob_contact = alice2.add_or_lookup_contact_id(bob).await;
|
||||
let a2_charlie_contact = alice2.add_or_lookup_contact_id(charlie).await;
|
||||
let a2_chatlist = Chatlist::try_load(alice2, 0, Some("Channel"), None).await?;
|
||||
let msg_id = a2_chatlist.get_msg_id(0)?.unwrap();
|
||||
let a2_bob_added = Message::load_from_db(alice2, msg_id).await?;
|
||||
assert_ne!(a2_bob_added.id, a2_charlie_added.id);
|
||||
assert_eq!(
|
||||
a2_bob_added.text,
|
||||
stock_str::msg_add_member_local(alice2, a2_bob_contact, ContactId::UNDEFINED).await
|
||||
);
|
||||
assert_eq!(a2_bob_added.from_id, ContactId::SELF);
|
||||
assert_eq!(
|
||||
a2_bob_added.param.get_cmd(),
|
||||
SystemMessage::MemberAddedToGroup
|
||||
);
|
||||
assert_eq!(
|
||||
ContactId::new(
|
||||
a2_bob_added
|
||||
.param
|
||||
.get_int(Param::ContactAddedRemoved)
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap()
|
||||
),
|
||||
a2_bob_contact
|
||||
);
|
||||
let a2_chat_id = a2_chatlist.get_chat_id(0).unwrap();
|
||||
|
||||
let a2_chat_members = get_chat_contacts(alice2, a2_charlie_added.chat_id).await?;
|
||||
// Also for Alice2, no info message should be shown;
|
||||
// she should see only the "Messages are end-to-end encrypted" message.
|
||||
let a2_chat_msgs = get_chat_msgs(alice2, a2_chat_id).await?;
|
||||
assert_eq!(a2_chat_msgs.len(), 1);
|
||||
let ChatItem::Message { msg_id } = a2_chat_msgs[0] else {
|
||||
unreachable!()
|
||||
};
|
||||
let a2_msg = Message::load_from_db(alice2, msg_id).await?;
|
||||
assert_eq!(a2_msg.get_info_type(), SystemMessage::ChatE2ee);
|
||||
|
||||
let a2_chat_members = get_chat_contacts(alice2, a2_chat_id).await?;
|
||||
assert!(a2_chat_members.contains(&a2_bob_contact));
|
||||
assert!(a2_chat_members.contains(&a2_charlie_contact));
|
||||
assert_eq!(a2_chat_members.len(), 2);
|
||||
|
||||
+30
-78
@@ -18,8 +18,8 @@ use crate::events::EventType;
|
||||
use crate::log::LogExt;
|
||||
use crate::mimefactory::RECOMMENDED_FILE_SIZE;
|
||||
use crate::sync::{self, Sync::*, SyncData};
|
||||
use crate::tools::{get_abs_path, time};
|
||||
use crate::transport::{add_pseudo_transport, send_sync_transports, transport_addrs};
|
||||
use crate::tools::get_abs_path;
|
||||
use crate::transport::transport_addrs;
|
||||
use crate::{constants, stats};
|
||||
|
||||
/// The available configuration keys.
|
||||
@@ -42,10 +42,12 @@ use crate::{constants, stats};
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum Config {
|
||||
/// Deprecated(2026-04).
|
||||
/// Use ConfiguredAddr, [`crate::login_param::EnteredLoginParam`],
|
||||
/// or add_transport{from_qr}()/list_transports() instead.
|
||||
///
|
||||
/// Email address, used in the `From:` field.
|
||||
/// Email address used by the deprecated configure() procedure.
|
||||
///
|
||||
/// Use add_transport{from_qr}() to configure new transports,
|
||||
/// Use list_transports() to learn about configured transports,
|
||||
/// including their addresses.
|
||||
Addr,
|
||||
|
||||
/// Deprecated(2026-04).
|
||||
@@ -195,9 +197,9 @@ pub enum Config {
|
||||
#[strum(props(default = "0"))]
|
||||
DeleteDeviceAfter,
|
||||
|
||||
/// The address of the transport used for sending.
|
||||
/// Deprecated(2026-09).
|
||||
///
|
||||
/// Device-local, other devices choose their own sending transport.
|
||||
/// Use ConfiguredLoginParam and list_transports() instead.
|
||||
ConfiguredAddr,
|
||||
|
||||
/// Deprecated(2026-04).
|
||||
@@ -305,18 +307,6 @@ pub enum Config {
|
||||
/// True if account is configured.
|
||||
Configured,
|
||||
|
||||
/// Deprecated, we are trying to get rid of this global setting.
|
||||
/// It is possible to configure a profile with both chatmail relays
|
||||
/// and classical email servers.
|
||||
///
|
||||
/// Most usages in UIs can be replaced by `force_encryption`.
|
||||
///
|
||||
/// True if account is a chatmail account.
|
||||
IsChatmail,
|
||||
|
||||
/// True if `IsChatmail` mustn't be autoconfigured. For tests.
|
||||
FixIsChatmail,
|
||||
|
||||
/// True if account is muted.
|
||||
IsMuted,
|
||||
|
||||
@@ -499,11 +489,6 @@ impl Config {
|
||||
| Self::ForceEncryption,
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether the config option needs an IO scheduler restart to take effect.
|
||||
pub(crate) fn needs_io_restart(&self) -> bool {
|
||||
matches!(self, Config::ConfiguredAddr)
|
||||
}
|
||||
}
|
||||
|
||||
impl Context {
|
||||
@@ -682,10 +667,6 @@ impl Context {
|
||||
pub async fn set_config(&self, key: Config, value: Option<&str>) -> Result<()> {
|
||||
Self::check_config(key, value)?;
|
||||
|
||||
let _pause = match key.needs_io_restart() {
|
||||
true => self.scheduler.pause(self).await?,
|
||||
_ => Default::default(),
|
||||
};
|
||||
if key == Config::StatsSending {
|
||||
let old_value = self.get_config(key).await?;
|
||||
let old_value = bool_from_config(old_value.as_deref());
|
||||
@@ -765,57 +746,28 @@ impl Context {
|
||||
bail!("Cannot unset configured_addr");
|
||||
};
|
||||
|
||||
if !self.is_configured().await? {
|
||||
info!(
|
||||
self,
|
||||
"Creating a pseudo configured account which will not be able to send or receive messages. Only meant for tests!"
|
||||
);
|
||||
add_pseudo_transport(self, addr).await?;
|
||||
self.sql
|
||||
.set_raw_config(Config::ConfiguredAddr.as_ref(), Some(addr))
|
||||
.await?;
|
||||
} else {
|
||||
self.sql
|
||||
.transaction(|transaction| {
|
||||
if transaction.query_row(
|
||||
"SELECT COUNT(*) FROM transports WHERE addr=?",
|
||||
(addr,),
|
||||
|row| {
|
||||
let res: i64 = row.get(0)?;
|
||||
Ok(res)
|
||||
},
|
||||
)? == 0
|
||||
{
|
||||
bail!("Address does not belong to any transport.");
|
||||
}
|
||||
transaction.execute(
|
||||
"UPDATE config SET value=? WHERE keyname='configured_addr'",
|
||||
(addr,),
|
||||
)?;
|
||||
self.sql
|
||||
.transaction(|transaction| {
|
||||
if transaction.query_row(
|
||||
"SELECT COUNT(*) FROM transports WHERE addr=?",
|
||||
(addr,),
|
||||
|row| {
|
||||
let res: i64 = row.get(0)?;
|
||||
Ok(res)
|
||||
},
|
||||
)? == 0
|
||||
{
|
||||
bail!("Address does not belong to any transport.");
|
||||
}
|
||||
transaction.execute(
|
||||
"INSERT OR REPLACE INTO config (keyname, value) VALUES ('configured_addr', ?)",
|
||||
(addr,),
|
||||
)?;
|
||||
|
||||
// The timestamp must strictly increase because
|
||||
// other devices ignore the row update otherwise,
|
||||
// and contacts only adopt the re-signed key
|
||||
// if its signature timestamp increases.
|
||||
transaction
|
||||
.execute(
|
||||
"UPDATE transports
|
||||
SET add_timestamp=MAX(?, add_timestamp+1)
|
||||
WHERE addr=?",
|
||||
(time(), addr),
|
||||
)
|
||||
.context(
|
||||
"Failed to update add_timestamp for the new sending transport",
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
// Invalidate the cache so the sync message
|
||||
// cannot read a stale sending address.
|
||||
self.sql.uncache_raw_config("configured_addr").await;
|
||||
send_sync_transports(self).await?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
self.sql.uncache_raw_config("configured_addr").await;
|
||||
}
|
||||
_ => {
|
||||
self.sql.set_raw_config(key.as_ref(), value).await?;
|
||||
|
||||
+119
-45
@@ -31,7 +31,7 @@ use crate::login_param::EnteredCertificateChecks;
|
||||
pub use crate::login_param::EnteredLoginParam;
|
||||
use crate::net::proxy::ProxyConfig;
|
||||
use crate::provider::{self, Protocol, Socket};
|
||||
use crate::qr::{login_param_from_account_qr, login_param_from_login_qr};
|
||||
use crate::qr::{Qr, check_qr, login_param_from_account_qr, login_param_from_login_qr};
|
||||
use crate::smtp::Smtp;
|
||||
use crate::sync::Sync::Nosync;
|
||||
use crate::tools::time;
|
||||
@@ -40,27 +40,30 @@ use crate::transport::{
|
||||
ConnectionCandidate, delete_transport_row, maybe_update_sending_transport,
|
||||
purge_transport_caches, send_sync_transports, transport_addrs,
|
||||
};
|
||||
use crate::{EventType, stock_str};
|
||||
use crate::{EventType, autorelay, stock_str};
|
||||
|
||||
/// Maximum number of relays.
|
||||
///
|
||||
/// See <https://github.com/chatmail/core/issues/7608>.
|
||||
pub(crate) const MAX_RELAYS: usize = 5;
|
||||
|
||||
macro_rules! progress {
|
||||
($context:tt, $progress:expr, $comment:expr) => {
|
||||
assert!(
|
||||
$progress <= 1000,
|
||||
"value in range 0..1000 expected with: 0=error, 1..999=progress, 1000=success"
|
||||
);
|
||||
$context.emit_event($crate::events::EventType::ConfigureProgress {
|
||||
progress: $progress,
|
||||
comment: $comment,
|
||||
});
|
||||
};
|
||||
($context:tt, $progress:expr) => {
|
||||
progress!($context, $progress, None);
|
||||
};
|
||||
tokio::task_local! {
|
||||
pub(crate) static SILENT_PROGRESS: ();
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn emit_progress(ctx: &Context, progress: u16) {
|
||||
assert!(
|
||||
progress <= 1000,
|
||||
"value in range 0..1000 expected with: 0=error, 1..999=progress, 1000=success"
|
||||
);
|
||||
if SILENT_PROGRESS.try_with(|_| ()).is_ok() {
|
||||
return;
|
||||
}
|
||||
ctx.emit_event(EventType::ConfigureProgress {
|
||||
progress,
|
||||
comment: None,
|
||||
});
|
||||
}
|
||||
|
||||
impl Context {
|
||||
@@ -124,14 +127,17 @@ impl Context {
|
||||
pub(crate) async fn add_transport_inner(&self, param: &mut EnteredLoginParam) -> Result<()> {
|
||||
match self.add_transport_unreported(param).await {
|
||||
Ok(()) => {
|
||||
progress!(self, 1000);
|
||||
emit_progress(self, 1000);
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
// We are using Anyhow's .context() and to show the
|
||||
// inner error, too, we need the {:#}:
|
||||
let error_msg = stock_str::configuration_failed(self, &format!("{err:#}"));
|
||||
progress!(self, 0, Some(error_msg.clone()));
|
||||
self.emit_event(EventType::ConfigureProgress {
|
||||
progress: 0,
|
||||
comment: Some(error_msg.clone()),
|
||||
});
|
||||
bail!(error_msg);
|
||||
}
|
||||
}
|
||||
@@ -190,6 +196,62 @@ impl Context {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds an initial transport on the chatmail relay that answers fastest
|
||||
/// and lets the profile add further ones in the background.
|
||||
///
|
||||
/// A `DCACCOUNT:` or `DCLOGIN:` `qr` code adds a single transport
|
||||
/// while securejoin codes add the inviter's relays to the candidates.
|
||||
///
|
||||
/// Does nothing if the profile already has a transport.
|
||||
pub async fn init_transports(&self, qr: Option<&str>) -> Result<()> {
|
||||
if self.is_configured().await? {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(qr) = qr {
|
||||
match check_qr(self, qr).await? {
|
||||
Qr::Account { .. } | Qr::Login { .. } => {
|
||||
return self.add_transport_from_qr(qr).await;
|
||||
}
|
||||
Qr::AskVerifyContact { addrs, .. }
|
||||
| Qr::AskVerifyGroup { addrs, .. }
|
||||
| Qr::AskJoinBroadcast { addrs, .. } => {
|
||||
autorelay::add_relay_candidates(self, &addrs).await?
|
||||
}
|
||||
_ => bail!("QR code does not contain a relay"),
|
||||
}
|
||||
}
|
||||
|
||||
let cancel_channel = self.alloc_ongoing().await?;
|
||||
let skip_network = false;
|
||||
let res = autorelay::add_transport_from_candidates(self, skip_network)
|
||||
.race(cancel_channel.recv().map(|_| Err(format_err!("Canceled"))))
|
||||
.await;
|
||||
self.free_ongoing().await;
|
||||
|
||||
let configured = self.is_configured().await?;
|
||||
match res {
|
||||
Ok(()) => {}
|
||||
Err(err) if configured => {
|
||||
warn!(
|
||||
self,
|
||||
"Onboarding interrupted after adding a transport: {err:#}."
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
let error_msg = stock_str::configuration_failed(self, &format!("{err:#}"));
|
||||
self.emit_event(EventType::ConfigureProgress {
|
||||
progress: 0,
|
||||
comment: Some(error_msg.clone()),
|
||||
});
|
||||
bail!(error_msg);
|
||||
}
|
||||
}
|
||||
self.set_config_bool(Config::Autorelay, true).await?;
|
||||
emit_progress(self, 1000);
|
||||
self.start_io().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the list of all email accounts that are used as a transport in the current profile.
|
||||
/// Use [Self::add_or_update_transport()] to add or change a transport
|
||||
/// and [Self::delete_transport()] to delete a transport.
|
||||
@@ -321,7 +383,7 @@ async fn get_configured_param(
|
||||
let parsed = EmailAddress::new(¶m.addr).context("Bad email-address")?;
|
||||
let param_domain = parsed.domain;
|
||||
|
||||
progress!(ctx, 200);
|
||||
emit_progress(ctx, 200);
|
||||
|
||||
let param_autoconfig = if param.imap.server.is_empty()
|
||||
&& param.imap.port == 0
|
||||
@@ -343,7 +405,7 @@ async fn get_configured_param(
|
||||
None
|
||||
};
|
||||
|
||||
progress!(ctx, 500);
|
||||
emit_progress(ctx, 500);
|
||||
|
||||
let mut servers = param_autoconfig.unwrap_or_default();
|
||||
if !servers
|
||||
@@ -437,13 +499,13 @@ pub(crate) async fn configure(
|
||||
param: &EnteredLoginParam,
|
||||
skip_network: bool,
|
||||
) -> Result<()> {
|
||||
progress!(ctx, 1);
|
||||
emit_progress(ctx, 1);
|
||||
|
||||
let configured_param = get_configured_param(ctx, param, skip_network).await?;
|
||||
let proxy_config = ProxyConfig::load(ctx).await?;
|
||||
let strict_tls = configured_param.strict_tls(proxy_config.is_some())?;
|
||||
|
||||
progress!(ctx, 550);
|
||||
emit_progress(ctx, 550);
|
||||
|
||||
if !skip_network {
|
||||
// Spawn SMTP configuration task
|
||||
@@ -469,7 +531,7 @@ pub(crate) async fn configure(
|
||||
Ok::<(), anyhow::Error>(())
|
||||
});
|
||||
|
||||
progress!(ctx, 600);
|
||||
emit_progress(ctx, 600);
|
||||
|
||||
// Configure IMAP
|
||||
|
||||
@@ -483,23 +545,12 @@ pub(crate) async fn configure(
|
||||
}
|
||||
};
|
||||
|
||||
progress!(ctx, 850);
|
||||
emit_progress(ctx, 850);
|
||||
|
||||
// Wait for SMTP configuration
|
||||
smtp_config_task.await??;
|
||||
|
||||
progress!(ctx, 900);
|
||||
|
||||
let is_configured = ctx.is_configured().await?;
|
||||
if !ctx.get_config_bool(Config::FixIsChatmail).await? {
|
||||
if imap_session.is_chatmail() {
|
||||
ctx.sql.set_raw_config("is_chatmail", Some("1")).await?;
|
||||
} else if !is_configured {
|
||||
// Reset the setting that may have been set
|
||||
// during failed configuration.
|
||||
ctx.sql.set_raw_config("is_chatmail", Some("0")).await?;
|
||||
}
|
||||
}
|
||||
emit_progress(ctx, 900);
|
||||
|
||||
// Drop the imap connection explicitly
|
||||
// to make sure that it's not forgotten in a future refactoring
|
||||
@@ -507,7 +558,7 @@ pub(crate) async fn configure(
|
||||
drop(imap);
|
||||
}
|
||||
|
||||
progress!(ctx, 910);
|
||||
emit_progress(ctx, 910);
|
||||
|
||||
configured_param
|
||||
.clone()
|
||||
@@ -518,11 +569,11 @@ pub(crate) async fn configure(
|
||||
ctx.set_config_internal(Config::ConfiguredTimestamp, Some(&time().to_string()))
|
||||
.await?;
|
||||
|
||||
progress!(ctx, 920);
|
||||
emit_progress(ctx, 920);
|
||||
|
||||
ctx.scheduler.interrupt_inbox().await;
|
||||
|
||||
progress!(ctx, 940);
|
||||
emit_progress(ctx, 940);
|
||||
ctx.update_device_chats()
|
||||
.await
|
||||
.context("Failed to update device chats")?;
|
||||
@@ -566,7 +617,7 @@ async fn get_autoconfig(
|
||||
{
|
||||
return Some(res);
|
||||
}
|
||||
progress!(ctx, 300);
|
||||
emit_progress(ctx, 300);
|
||||
|
||||
// `?emailaddress=` query string is excluded on purpose.
|
||||
// It is not part of the URL according to <https://datatracker.ietf.org/doc/draft-ietf-mailmaint-autoconfig/06/>.
|
||||
@@ -581,7 +632,7 @@ async fn get_autoconfig(
|
||||
{
|
||||
return Some(res);
|
||||
}
|
||||
progress!(ctx, 310);
|
||||
emit_progress(ctx, 310);
|
||||
|
||||
// Outlook uses always SSL but different domains (this comment describes the next two steps)
|
||||
if let Ok(res) = outlk_autodiscover(
|
||||
@@ -593,7 +644,7 @@ async fn get_autoconfig(
|
||||
{
|
||||
return Some(res);
|
||||
}
|
||||
progress!(ctx, 320);
|
||||
emit_progress(ctx, 320);
|
||||
|
||||
if let Ok(res) = outlk_autodiscover(
|
||||
ctx,
|
||||
@@ -604,7 +655,7 @@ async fn get_autoconfig(
|
||||
{
|
||||
return Some(res);
|
||||
}
|
||||
progress!(ctx, 330);
|
||||
emit_progress(ctx, 330);
|
||||
|
||||
// always SSL for Thunderbird's database
|
||||
if let Ok(res) = moz_autoconfigure(
|
||||
@@ -681,7 +732,8 @@ mod tests {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_early_configure_failure_is_reported() -> Result<()> {
|
||||
let t = TestContext::new().await;
|
||||
let mut param = login_param_from_host("example.org");
|
||||
let mark_as_autorelay = false;
|
||||
let mut param = login_param_from_host("example.org", mark_as_autorelay);
|
||||
|
||||
// An ongoing process, e.g. a backup import,
|
||||
// makes configuration fail without ever contacting a relay.
|
||||
@@ -700,6 +752,28 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that init_transports() fails on a bad code
|
||||
/// and does nothing on a profile that already has a transport.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_init_transports() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let t = &tcm.unconfigured().await;
|
||||
assert!(t.init_transports(Some("not a qr code")).await.is_err());
|
||||
assert!(!t.is_configured().await?);
|
||||
|
||||
let alice = &tcm.alice().await;
|
||||
let invite = "openpgp4fpr:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40invite.example&i=TbnwJ6lSvD5&s=0ejvbdFSQxB";
|
||||
alice.init_transports(Some(invite)).await?;
|
||||
let candidates = alice
|
||||
.sql
|
||||
.count("SELECT COUNT(*) FROM relay_candidates", ())
|
||||
.await?;
|
||||
assert_eq!(candidates, 0);
|
||||
assert_eq!(alice.count_transports().await?, 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_get_configured_param() -> Result<()> {
|
||||
let t = &TestContext::new().await;
|
||||
@@ -726,7 +800,7 @@ mod tests {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let t = &tcm.unconfigured().await;
|
||||
|
||||
// Setting ConfiguredAddr on an unconfigured account creates a pseudo transport
|
||||
add_pseudo_transport(t, "primary@example.org").await?;
|
||||
t.set_config(Config::ConfiguredAddr, Some("primary@example.org"))
|
||||
.await?;
|
||||
assert_eq!(t.count_transports().await?, 1);
|
||||
|
||||
+45
-28
@@ -40,9 +40,33 @@ use crate::sync::{self, Sync::*};
|
||||
use crate::tools::{SystemTime, duration_to_str, get_abs_path, normalize_text, time, to_lowercase};
|
||||
use crate::{chat, chatlist_events, ensure_and_debug_assert, stock_str};
|
||||
|
||||
/// Time during which a contact is considered as seen recently.
|
||||
/// If the contact's "last seen" is newer, the contact freshness is set to "recently seen".
|
||||
const SEEN_RECENTLY_SECONDS: i64 = 600;
|
||||
|
||||
/// If the contact's "last seen" is older, the contact freshness is set to "old".
|
||||
const CONTACT_OLD_SECONDS: i64 = 60 * 24 * 60 * 60;
|
||||
|
||||
/// Freshness of a contact, based on when it was last seen.
|
||||
///
|
||||
/// Used by the UI to highlight contacts:
|
||||
/// recently seen contacts get a little green dot on the avatar,
|
||||
/// contacts not seen for a long time get a string below the name (e.g. "Seen 2 months ago").
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Freshness {
|
||||
/// Contact shall not be highlighted.
|
||||
Normal = 0,
|
||||
/// Contact was seen recently.
|
||||
RecentlySeen = 1,
|
||||
/// Contact was not seen for a long time.
|
||||
Old = 2,
|
||||
}
|
||||
|
||||
impl From<Freshness> for u32 {
|
||||
fn from(freshness: Freshness) -> Self {
|
||||
freshness as u32
|
||||
}
|
||||
}
|
||||
|
||||
/// Contact ID, including reserved IDs.
|
||||
///
|
||||
/// Some contact IDs are reserved to identify special contacts. This
|
||||
@@ -486,8 +510,8 @@ pub struct Contact {
|
||||
/// The contact ID.
|
||||
pub id: ContactId,
|
||||
|
||||
/// Contact name. It is recommended to use `Contact::get_name`,
|
||||
/// `Contact::get_display_name` or `Contact::get_name_n_addr` to access this field.
|
||||
/// Contact name. It is recommended to use `Contact::get_name`
|
||||
/// or `Contact::get_display_name` to access this field.
|
||||
/// May be empty, initially set to `authname`.
|
||||
name: String,
|
||||
|
||||
@@ -725,10 +749,23 @@ impl Contact {
|
||||
self.last_seen
|
||||
}
|
||||
|
||||
/// Returns `true` if this contact was seen recently.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub fn was_seen_recently(&self) -> bool {
|
||||
time() - self.last_seen <= SEEN_RECENTLY_SECONDS
|
||||
/// Returns freshness of the contact.
|
||||
pub fn get_freshness(&self) -> Freshness {
|
||||
if self.id.is_special() || !self.is_key_contact() || self.is_blocked() {
|
||||
return Freshness::Normal;
|
||||
}
|
||||
|
||||
let is_old = time().saturating_sub(self.last_seen) > CONTACT_OLD_SECONDS;
|
||||
if is_old || self.last_seen <= 0 {
|
||||
return Freshness::Old;
|
||||
}
|
||||
|
||||
let seen_recently = time().saturating_sub(self.last_seen) <= SEEN_RECENTLY_SECONDS;
|
||||
if seen_recently {
|
||||
return Freshness::RecentlySeen;
|
||||
}
|
||||
|
||||
Freshness::Normal
|
||||
}
|
||||
|
||||
/// Check if a contact is blocked.
|
||||
@@ -1576,7 +1613,7 @@ WHERE addr=?
|
||||
/// May be an empty string.
|
||||
///
|
||||
/// This name is typically used in a form where the user can edit the name of a contact.
|
||||
/// To get a fine name to display in lists etc., use `Contact::get_display_name` or `Contact::get_name_n_addr`.
|
||||
/// To get a fine name to display in lists etc., use `Contact::get_display_name`.
|
||||
pub fn get_name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
@@ -1596,26 +1633,6 @@ WHERE addr=?
|
||||
&self.addr
|
||||
}
|
||||
|
||||
/// Get a summary of name and address.
|
||||
///
|
||||
/// The returned string is either "Name (email@domain.com)" or just
|
||||
/// "email@domain.com" if the name is unset.
|
||||
///
|
||||
/// The result should only be used locally and never sent over the network
|
||||
/// as it leaks the local contact name.
|
||||
///
|
||||
/// The summary is typically used when asking the user something about the contact.
|
||||
/// The attached email address makes the question unique, eg. "Chat with Alan Miller (am@uniquedomain.com)?"
|
||||
pub fn get_name_n_addr(&self) -> String {
|
||||
if !self.name.is_empty() {
|
||||
format!("{} ({})", self.name, self.addr)
|
||||
} else if !self.authname.is_empty() {
|
||||
format!("{} ({})", self.authname, self.addr)
|
||||
} else {
|
||||
(&self.addr).into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the contact's profile image.
|
||||
/// This is the image set by each remote user on their own
|
||||
/// using set_config(context, "selfavatar", image).
|
||||
|
||||
@@ -253,7 +253,6 @@ async fn test_add_or_lookup() {
|
||||
assert_eq!(contact.get_authname(), "bla foo");
|
||||
assert_eq!(contact.get_display_name(), "Name one");
|
||||
assert_eq!(contact.get_addr(), "one@eins.org");
|
||||
assert_eq!(contact.get_name_n_addr(), "Name one (one@eins.org)");
|
||||
|
||||
// modify first added contact
|
||||
let (contact_id_test, sth_modified) = Contact::add_or_lookup(
|
||||
@@ -286,7 +285,6 @@ async fn test_add_or_lookup() {
|
||||
assert_eq!(contact.get_name(), "");
|
||||
assert_eq!(contact.get_display_name(), "three@drei.sam");
|
||||
assert_eq!(contact.get_addr(), "three@drei.sam");
|
||||
assert_eq!(contact.get_name_n_addr(), "three@drei.sam");
|
||||
|
||||
// add name to third contact from incoming message (this becomes authorized name)
|
||||
let (contact_id_test, sth_modified) = Contact::add_or_lookup(
|
||||
@@ -300,7 +298,6 @@ async fn test_add_or_lookup() {
|
||||
assert_eq!(contact_id, contact_id_test);
|
||||
assert_eq!(sth_modified, Modifier::Modified);
|
||||
let contact = Contact::get_by_id(&t, contact_id).await.unwrap();
|
||||
assert_eq!(contact.get_name_n_addr(), "m. serious (three@drei.sam)");
|
||||
assert!(!contact.is_blocked());
|
||||
|
||||
// manually edit name of third contact (does not changed authorized name)
|
||||
@@ -316,7 +313,6 @@ async fn test_add_or_lookup() {
|
||||
assert_eq!(sth_modified, Modifier::Modified);
|
||||
let contact = Contact::get_by_id(&t, contact_id).await.unwrap();
|
||||
assert_eq!(contact.get_authname(), "m. serious");
|
||||
assert_eq!(contact.get_name_n_addr(), "schnucki (three@drei.sam)");
|
||||
assert!(!contact.is_blocked());
|
||||
|
||||
// Fourth contact:
|
||||
@@ -334,7 +330,6 @@ async fn test_add_or_lookup() {
|
||||
assert_eq!(contact.get_name(), "Wonderland, Alice");
|
||||
assert_eq!(contact.get_display_name(), "Wonderland, Alice");
|
||||
assert_eq!(contact.get_addr(), "alice@w.de");
|
||||
assert_eq!(contact.get_name_n_addr(), "Wonderland, Alice (alice@w.de)");
|
||||
|
||||
// check SELF
|
||||
let contact = Contact::get_by_id(&t, ContactId::SELF).await.unwrap();
|
||||
@@ -373,7 +368,6 @@ async fn test_contact_name_changes() -> Result<()> {
|
||||
assert_eq!(contact.get_authname(), "");
|
||||
assert_eq!(contact.get_name(), "");
|
||||
assert_eq!(contact.get_display_name(), "f@example.org");
|
||||
assert_eq!(contact.get_name_n_addr(), "f@example.org");
|
||||
let contacts = Contact::get_all(&t, 0, Some("f@example.org")).await?;
|
||||
assert_eq!(contacts.len(), 0);
|
||||
|
||||
@@ -399,7 +393,6 @@ async fn test_contact_name_changes() -> Result<()> {
|
||||
assert_eq!(contact.get_authname(), "Flobbyfoo");
|
||||
assert_eq!(contact.get_name(), "");
|
||||
assert_eq!(contact.get_display_name(), "Flobbyfoo");
|
||||
assert_eq!(contact.get_name_n_addr(), "Flobbyfoo (f@example.org)");
|
||||
let contacts = Contact::get_all(&t, 0, Some("f@example.org")).await?;
|
||||
assert_eq!(contacts.len(), 0);
|
||||
let contacts = Contact::get_all(&t, 0, Some("flobbyfoo")).await?;
|
||||
@@ -429,7 +422,6 @@ async fn test_contact_name_changes() -> Result<()> {
|
||||
assert_eq!(contact.get_authname(), "Foo Flobby");
|
||||
assert_eq!(contact.get_name(), "");
|
||||
assert_eq!(contact.get_display_name(), "Foo Flobby");
|
||||
assert_eq!(contact.get_name_n_addr(), "Foo Flobby (f@example.org)");
|
||||
let contacts = Contact::get_all(&t, 0, Some("f@example.org")).await?;
|
||||
assert_eq!(contacts.len(), 0);
|
||||
let contacts = Contact::get_all(&t, 0, Some("flobbyfoo")).await?;
|
||||
@@ -447,7 +439,6 @@ async fn test_contact_name_changes() -> Result<()> {
|
||||
assert_eq!(contact.get_authname(), "Foo Flobby");
|
||||
assert_eq!(contact.get_name(), "Falk");
|
||||
assert_eq!(contact.get_display_name(), "Falk");
|
||||
assert_eq!(contact.get_name_n_addr(), "Falk (f@example.org)");
|
||||
let contacts = Contact::get_all(&t, 0, Some("f@example.org")).await?;
|
||||
assert_eq!(contacts.len(), 0);
|
||||
let contacts = Contact::get_all(&t, 0, Some("falk")).await?;
|
||||
@@ -1057,7 +1048,7 @@ async fn test_last_seen() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_was_seen_recently() -> Result<()> {
|
||||
async fn test_contact_freshness() -> Result<()> {
|
||||
let _n = TimeShiftFalsePositiveNote;
|
||||
|
||||
let mut tcm = TestContextManager::new();
|
||||
@@ -1070,15 +1061,57 @@ async fn test_was_seen_recently() -> Result<()> {
|
||||
let chat = bob.create_chat(&alice).await;
|
||||
let contacts = chat::get_chat_contacts(&bob, chat.id).await?;
|
||||
let contact = Contact::get_by_id(&bob, *contacts.first().unwrap()).await?;
|
||||
assert!(!contact.was_seen_recently());
|
||||
assert_eq!(contact.get_freshness(), Freshness::Old);
|
||||
|
||||
bob.recv_msg(&sent_msg).await;
|
||||
let contact = Contact::get_by_id(&bob, *contacts.first().unwrap()).await?;
|
||||
|
||||
assert!(contact.was_seen_recently());
|
||||
assert_eq!(contact.get_freshness(), Freshness::RecentlySeen);
|
||||
|
||||
let self_contact = Contact::get_by_id(&bob, ContactId::SELF).await?;
|
||||
assert!(!self_contact.was_seen_recently());
|
||||
assert_eq!(self_contact.get_freshness(), Freshness::Normal);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_contact_freshness_blocked() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = tcm.alice().await;
|
||||
let bob = tcm.bob().await;
|
||||
|
||||
// Alice sends message to Bob. Bob receives message, contact's freshness is "recently seen"
|
||||
let msg = tcm.send_recv(&alice, &bob, "moin").await;
|
||||
|
||||
let contact = Contact::get_by_id(&bob, msg.from_id).await?;
|
||||
assert_eq!(contact.get_freshness(), Freshness::RecentlySeen);
|
||||
|
||||
// Bob blocks Alice, contact's freshness is "normal" now
|
||||
Contact::block(&bob, msg.from_id).await?;
|
||||
let contact = Contact::get_by_id(&bob, msg.from_id).await?;
|
||||
assert!(contact.is_blocked());
|
||||
assert_eq!(contact.get_freshness(), Freshness::Normal);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_contact_freshness_address_contact() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = tcm.alice().await;
|
||||
alice.allow_unencrypted().await?;
|
||||
let bob = tcm.bob().await;
|
||||
bob.allow_unencrypted().await?;
|
||||
|
||||
// Alice sends a message to Bob
|
||||
let alice_chat = alice.create_email_chat(&bob).await;
|
||||
let sent_msg = alice.send_text(alice_chat.id, "moin").await;
|
||||
|
||||
// Bob receives message, contact's freshness is "normal", even though the messages was just received
|
||||
let msg = bob.recv_msg(&sent_msg).await;
|
||||
let contact = Contact::get_by_id(&bob, msg.from_id).await?;
|
||||
assert!(!contact.is_key_contact());
|
||||
assert_eq!(contact.get_freshness(), Freshness::Normal);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1096,11 +1129,11 @@ async fn test_was_seen_recently_event() -> Result<()> {
|
||||
let chat = alice.create_chat(&bob).await;
|
||||
let sent_msg = alice.send_text(chat.id, "moin").await;
|
||||
let contact = Contact::get_by_id(&bob, *contacts.first().unwrap()).await?;
|
||||
assert!(!contact.was_seen_recently());
|
||||
assert_ne!(contact.get_freshness(), Freshness::RecentlySeen);
|
||||
bob.evtracker.clear_events();
|
||||
bob.recv_msg(&sent_msg).await;
|
||||
let contact = Contact::get_by_id(&bob, *contacts.first().unwrap()).await?;
|
||||
assert!(contact.was_seen_recently());
|
||||
assert_eq!(contact.get_freshness(), Freshness::RecentlySeen);
|
||||
bob.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::ContactsChanged { .. }))
|
||||
.await;
|
||||
@@ -1108,12 +1141,12 @@ async fn test_was_seen_recently_event() -> Result<()> {
|
||||
.interrupt(contact.id, contact.last_seen)
|
||||
.await;
|
||||
|
||||
// Wait for `was_seen_recently()` to turn off.
|
||||
// Wait for "seen recently" to turn off.
|
||||
bob.evtracker.clear_events();
|
||||
SystemTime::shift(Duration::from_secs(SEEN_RECENTLY_SECONDS as u64 * 2));
|
||||
recently_seen_loop.interrupt(ContactId::UNDEFINED, 0).await;
|
||||
let contact = Contact::get_by_id(&bob, *contacts.first().unwrap()).await?;
|
||||
assert!(!contact.was_seen_recently());
|
||||
assert_eq!(contact.get_freshness(), Freshness::Normal);
|
||||
bob.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::ContactsChanged { .. }))
|
||||
.await;
|
||||
|
||||
+14
-29
@@ -568,20 +568,9 @@ impl Context {
|
||||
self.scheduler.maybe_network().await;
|
||||
}
|
||||
|
||||
/// Deprecated, we are trying to get rid of this global setting.
|
||||
/// It is possible to configure a profile with both chatmail relays
|
||||
/// and classical email servers.
|
||||
///
|
||||
/// Returns true if an account is on a chatmail server.
|
||||
pub async fn is_chatmail(&self) -> Result<bool> {
|
||||
self.get_config_bool(Config::IsChatmail).await
|
||||
}
|
||||
|
||||
/// Returns maximum number of recipients a single email can be sent to.
|
||||
pub(crate) async fn get_max_smtp_rcpt_to(&self) -> Result<u32> {
|
||||
let Some((transport_id, param)) = ConfiguredLoginParam::load(self).await? else {
|
||||
bail!("Not configured");
|
||||
};
|
||||
/// Returns maximum number of recipients a single email can be sent to
|
||||
/// over the transport `transport_id`, which sends from `addr`.
|
||||
pub(crate) async fn get_max_smtp_rcpt_to(&self, transport_id: u32, addr: &str) -> Result<u32> {
|
||||
let metadata_limit = self
|
||||
.metadata
|
||||
.read()
|
||||
@@ -591,9 +580,7 @@ impl Context {
|
||||
if let Some(limit) = metadata_limit {
|
||||
return Ok(limit);
|
||||
}
|
||||
if let Some(limit) =
|
||||
crate::provider::legacy_settings_for_addr(¶m.addr)?.max_smtp_rcpt_to
|
||||
{
|
||||
if let Some(limit) = crate::provider::legacy_settings_for_addr(addr)?.max_smtp_rcpt_to {
|
||||
return Ok(limit);
|
||||
}
|
||||
Ok(constants::DEFAULT_MAX_SMTP_RCPT_TO)
|
||||
@@ -601,9 +588,13 @@ impl Context {
|
||||
|
||||
/// Does a single round of fetching messages from all transports and returns.
|
||||
///
|
||||
/// Can be used even if I/O is currently stopped.
|
||||
/// If I/O is stopped, fetches over a dedicated connection per transport
|
||||
/// and returns as soon as one of them fetched messages.
|
||||
/// If IO is stopped, pauses the scheduler and fetches over a dedicated connection
|
||||
/// per transport, returning as soon as one of them fetched messages.
|
||||
/// If IO is running, interrupts IMAP IDLE on all transports
|
||||
/// and waits until they are done fetching.
|
||||
///
|
||||
/// Does not wait for outgoing messages to be sent out,
|
||||
/// use [`crate::accounts::Accounts::is_sending_finished`] for that.
|
||||
pub async fn background_fetch(&self) -> Result<()> {
|
||||
if !(self.is_configured().await?) {
|
||||
return Ok(());
|
||||
@@ -613,8 +604,9 @@ impl Context {
|
||||
info!(self, "background_fetch started.");
|
||||
|
||||
if self.scheduler.is_running().await {
|
||||
self.scheduler.maybe_network().await;
|
||||
self.wait_for_all_work_done().await;
|
||||
self.scheduler.interrupt_inbox_idle().await;
|
||||
let include_smtp = false;
|
||||
self.wait_for_work_done(include_smtp).await;
|
||||
} else {
|
||||
self.scheduler.background_fetch_any(self).await?;
|
||||
}
|
||||
@@ -880,13 +872,6 @@ impl Context {
|
||||
res.insert("imap_server_id", format!("{server_id:?}"));
|
||||
}
|
||||
|
||||
res.insert("is_chatmail", self.is_chatmail().await?.to_string());
|
||||
res.insert(
|
||||
"fix_is_chatmail",
|
||||
self.get_config_bool(Config::FixIsChatmail)
|
||||
.await?
|
||||
.to_string(),
|
||||
);
|
||||
res.insert(
|
||||
"is_muted",
|
||||
self.get_config_bool(Config::IsMuted).await?.to_string(),
|
||||
|
||||
@@ -5,12 +5,35 @@ use tempfile::tempdir;
|
||||
use super::*;
|
||||
use crate::chat::{Chat, MuteDuration, get_chat_contacts, get_chat_msgs, send_msg, set_muted};
|
||||
use crate::chatlist::Chatlist;
|
||||
use crate::constants::Chattype;
|
||||
use crate::constants::{Chattype, DEFAULT_MAX_SMTP_RCPT_TO};
|
||||
use crate::message::Message;
|
||||
use crate::receive_imf::receive_imf;
|
||||
use crate::test_utils::{E2EE_INFO_MSGS, TestContext, TestContextManager};
|
||||
use crate::tools::{SystemTime, create_outgoing_rfc724_mid};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_get_max_smtp_rcpt_to() -> Result<()> {
|
||||
let t = TestContext::new().await;
|
||||
assert_eq!(
|
||||
t.get_max_smtp_rcpt_to(2, "alice@example.org").await?,
|
||||
DEFAULT_MAX_SMTP_RCPT_TO
|
||||
);
|
||||
|
||||
for (transport_id, limit) in [(1, 3), (2, 7)] {
|
||||
t.metadata.write().await.insert(
|
||||
transport_id,
|
||||
ServerMetadata {
|
||||
max_smtp_rcpt_to: Some(limit),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(t.get_max_smtp_rcpt_to(2, "alice@example.org").await?, 7);
|
||||
assert_eq!(t.get_max_smtp_rcpt_to(1, "alice@example.org").await?, 3);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_wrong_db() -> Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
|
||||
+1
-3
@@ -6,7 +6,6 @@ use anyhow::{Result, anyhow, bail, ensure};
|
||||
use deltachat_derive::{FromSql, ToSql};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::context::Context;
|
||||
use crate::imap::session::Session;
|
||||
use crate::log::warn;
|
||||
@@ -164,8 +163,7 @@ pub(crate) async fn download_msg(
|
||||
.fetch_single_msg(context, &server_folder, server_uid, rfc724_mid)
|
||||
.await?;
|
||||
|
||||
let bcc_self = context.get_config_bool(Config::BccSelf).await?;
|
||||
if ephemeral::should_delete_all_downloaded_messages(bcc_self, session.is_chatmail()) {
|
||||
if ephemeral::should_delete_all_downloaded_messages(context).await? {
|
||||
// Now that the message was downloaded, it likely needs to be deleted;
|
||||
// trigger a re-check by interrupting the inbox folder.
|
||||
// This is mainly needed to make the tests pass;
|
||||
|
||||
@@ -6,7 +6,6 @@ mod tests {
|
||||
|
||||
use crate::chat;
|
||||
use crate::chat::send_text_msg;
|
||||
use crate::config::Config;
|
||||
use crate::message::Message;
|
||||
use crate::mimeparser::SystemMessage;
|
||||
use crate::receive_imf::receive_imf;
|
||||
@@ -61,7 +60,6 @@ Sent with my Delta Chat Messenger: https://delta.chat";
|
||||
async fn test_chatmail_can_send_unencrypted() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let bob = &tcm.bob().await;
|
||||
bob.set_config_bool(Config::IsChatmail, true).await?;
|
||||
bob.allow_unencrypted().await?;
|
||||
let bob_chat_id = receive_imf(
|
||||
bob,
|
||||
|
||||
+5
-5
@@ -654,12 +654,11 @@ pub(crate) async fn ephemeral_loop(context: &Context, interrupt_receiver: Receiv
|
||||
pub(crate) async fn delete_expired_imap_messages(
|
||||
context: &Context,
|
||||
transport_id: u32,
|
||||
is_chatmail: bool,
|
||||
) -> Result<()> {
|
||||
let now = time();
|
||||
|
||||
let bcc_self = context.get_config_bool(Config::BccSelf).await?;
|
||||
if should_delete_all_downloaded_messages(bcc_self, is_chatmail) {
|
||||
if should_delete_all_downloaded_messages(context).await? {
|
||||
// This is the only device using this relay.
|
||||
// Mark all downloaded messages for deletion, because they are not needed anymore.
|
||||
//
|
||||
@@ -708,7 +707,7 @@ pub(crate) async fn delete_expired_imap_messages(
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
// Single device.
|
||||
// Single device, but unencrypted messages are allowed.
|
||||
// Delete all expired and encrypted messages.
|
||||
context
|
||||
.sql
|
||||
@@ -736,8 +735,9 @@ pub(crate) async fn delete_expired_imap_messages(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn should_delete_all_downloaded_messages(bcc_self: bool, is_chatmail: bool) -> bool {
|
||||
!bcc_self && is_chatmail
|
||||
pub(crate) async fn should_delete_all_downloaded_messages(context: &Context) -> Result<bool> {
|
||||
Ok(!context.get_config_bool(Config::BccSelf).await?
|
||||
&& context.get_config_bool(Config::ForceEncryption).await?)
|
||||
}
|
||||
|
||||
/// Start ephemeral timers for seen messages if they are not started
|
||||
|
||||
@@ -509,7 +509,7 @@ async fn test_delete_expired_imap_messages() -> Result<()> {
|
||||
.await?;
|
||||
}
|
||||
|
||||
for (is_chatmail, other_transport, bcc_self) in [
|
||||
for (force_encryption, other_transport, bcc_self) in [
|
||||
(false, false, false),
|
||||
(false, false, true),
|
||||
(false, true, false),
|
||||
@@ -520,10 +520,12 @@ async fn test_delete_expired_imap_messages() -> Result<()> {
|
||||
(true, true, true),
|
||||
] {
|
||||
println!(
|
||||
"Testing combination is_chatmail={is_chatmail}, other_transport={other_transport}, bcc_self={bcc_self}"
|
||||
"Testing combination force_encryption={force_encryption}, other_transport={other_transport}, bcc_self={bcc_self}"
|
||||
);
|
||||
|
||||
t.set_config_bool(Config::BccSelf, bcc_self).await?;
|
||||
t.set_config_bool(Config::ForceEncryption, force_encryption)
|
||||
.await?;
|
||||
|
||||
delete_expired_imap_messages(
|
||||
&t,
|
||||
@@ -532,7 +534,6 @@ async fn test_delete_expired_imap_messages() -> Result<()> {
|
||||
} else {
|
||||
transport_id
|
||||
},
|
||||
is_chatmail,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -548,7 +549,7 @@ async fn test_delete_expired_imap_messages() -> Result<()> {
|
||||
assert_eq!(is_deleted(&t, "no_expire@localhost").await?, !bcc_self);
|
||||
assert_eq!(
|
||||
is_deleted(&t, "no_expire_unencrypted@localhost").await?,
|
||||
is_chatmail && !bcc_self
|
||||
force_encryption && !bcc_self
|
||||
);
|
||||
assert_eq!(is_deleted(&t, "future@localhost").await?, !bcc_self);
|
||||
assert_eq!(is_deleted(&t, "expired_post@localhost").await?, true);
|
||||
@@ -563,9 +564,10 @@ async fn test_delete_expired_imap_messages() -> Result<()> {
|
||||
reset_targets(&t).await;
|
||||
}
|
||||
|
||||
// With BccSelf=true, non-expired messages are kept even if `is_chatmail` is true
|
||||
// With BccSelf=true, non-expired messages are kept even if `force_encryption` is true
|
||||
t.set_config_bool(Config::BccSelf, true).await?;
|
||||
delete_expired_imap_messages(&t, transport_id, true).await?;
|
||||
t.set_config_bool(Config::ForceEncryption, true).await?;
|
||||
delete_expired_imap_messages(&t, transport_id).await?;
|
||||
assert_eq!(is_deleted(&t, "expired@localhost").await?, true);
|
||||
assert_eq!(is_deleted(&t, "no_expire@localhost").await?, false);
|
||||
assert_eq!(is_deleted(&t, "done_pre@localhost").await?, false);
|
||||
@@ -682,6 +684,10 @@ async fn test_ephemeral_msg_offline() -> Result<()> {
|
||||
check_msg_will_be_deleted(alice, msg.id, &chat, now, now + i64::from(duration) + 1).await?;
|
||||
assert!(alice.sql.exists(stmt, (msg.id,)).await?);
|
||||
|
||||
alice
|
||||
.assert_warn("No SMTP connection candidates provided")
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -33,9 +33,6 @@ pub enum EventType {
|
||||
/// Emitted when an IMAP message has been marked as deleted
|
||||
ImapMessageDeleted(String),
|
||||
|
||||
/// Emitted when an IMAP message has been moved
|
||||
ImapMessageMoved(String),
|
||||
|
||||
/// Emitted before going into IDLE on the Inbox folder.
|
||||
ImapInboxIdle,
|
||||
|
||||
@@ -363,6 +360,9 @@ pub enum EventType {
|
||||
/// A call made while another background fetch is running gets the event immediately,
|
||||
/// and the running fetch keeps emitting events until its own marker.
|
||||
///
|
||||
/// The event carries no data identifying the call it belongs to,
|
||||
/// so it is unambiguous only if there are no concurrent background fetch calls.
|
||||
///
|
||||
/// This event is only emitted by the account manager.
|
||||
AccountsBackgroundFetchDone,
|
||||
/// Inform that set of chats or the order of the chats in the chatlist has changed.
|
||||
|
||||
+28
-104
@@ -129,6 +129,8 @@ pub(crate) struct ServerMetadata {
|
||||
/// True if we think the relay supports push notifications.
|
||||
/// This gates wether we attempt to write an encrypted device token
|
||||
/// to per-transport IMAP metadata key `/private/devicetoken`.
|
||||
///
|
||||
/// Any `/shared/vendor/deltachat/` entry identifies a chatmail relay.
|
||||
pub supports_push: bool,
|
||||
|
||||
/// ICE servers for WebRTC calls.
|
||||
@@ -299,7 +301,8 @@ impl Imap {
|
||||
self.conn_backoff_ms = max(BACKOFF_MIN_MS, self.conn_backoff_ms);
|
||||
|
||||
let login_params = prioritize_server_login_params(&context.sql, &self.lp, "imap").await?;
|
||||
let mut first_error = None;
|
||||
let mut first_connection_error = None;
|
||||
let mut first_login_error = None;
|
||||
'candidate: for lp in login_params {
|
||||
info!(context, "IMAP trying to connect to {}.", lp.connection);
|
||||
let connection_candidate = lp.connection.clone();
|
||||
@@ -315,7 +318,7 @@ impl Imap {
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
warn!(context, "{err:#}.");
|
||||
first_error.get_or_insert(err);
|
||||
first_connection_error.get_or_insert(err);
|
||||
continue 'candidate;
|
||||
}
|
||||
};
|
||||
@@ -394,12 +397,14 @@ impl Imap {
|
||||
|
||||
Err(err) => {
|
||||
warn!(context, "{err:#}.");
|
||||
first_error.get_or_insert(err);
|
||||
first_login_error.get_or_insert(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(first_error.unwrap_or_else(|| format_err!("No IMAP connection candidates provided")))
|
||||
Err(first_login_error
|
||||
.or(first_connection_error)
|
||||
.unwrap_or_else(|| format_err!("No IMAP connection candidates provided")))
|
||||
}
|
||||
|
||||
/// Prepare a new IMAP session.
|
||||
@@ -418,13 +423,13 @@ impl Imap {
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// FETCH-MOVE-DELETE iteration.
|
||||
/// FETCH-and-DELETE iteration.
|
||||
///
|
||||
/// Prefetches headers and downloads new message from the folder, moves messages away from the
|
||||
/// folder and deletes messages in the folder.
|
||||
/// Prefetches headers and downloads new message from the folder
|
||||
/// and deletes messages in the folder.
|
||||
///
|
||||
/// Returns true if at least one message was fetched.
|
||||
pub async fn fetch_move_delete(
|
||||
pub async fn fetch_delete(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
session: &mut Session,
|
||||
@@ -450,14 +455,14 @@ impl Imap {
|
||||
|
||||
// Mark expired messages for deletion. Note that `delete_expired_imap_messages` is
|
||||
// not well optimized and should not be called before fetching.
|
||||
delete_expired_imap_messages(context, session.transport_id(), session.is_chatmail())
|
||||
delete_expired_imap_messages(context, session.transport_id())
|
||||
.await
|
||||
.context("delete_expired_imap_messages")?;
|
||||
|
||||
session
|
||||
.move_delete_messages(context, watch_folder)
|
||||
.delete_messages(context, watch_folder)
|
||||
.await
|
||||
.context("move_delete_messages")?;
|
||||
.context("delete_messages")?;
|
||||
|
||||
Ok(msgs_fetched)
|
||||
}
|
||||
@@ -568,16 +573,6 @@ impl Imap {
|
||||
.size
|
||||
.context("imap fetch response does not contain size")?;
|
||||
|
||||
// Determine the target folder where the message should be moved to.
|
||||
//
|
||||
// We only move the messages from the INBOX and Spam folders.
|
||||
// This is required to avoid infinite MOVE loop on IMAP servers
|
||||
// that alias `DeltaChat` folder to other names.
|
||||
// For example, some Dovecot servers alias `DeltaChat` folder to `INBOX.DeltaChat`.
|
||||
// In this case moving from `INBOX.DeltaChat` to `DeltaChat`
|
||||
// results in the messages getting a new UID,
|
||||
// so the messages will be detected as new
|
||||
// in the `INBOX.DeltaChat` folder again.
|
||||
let delete = if let Some(message_id) = &message_id {
|
||||
message::rfc724_mid_exists_ext(context, message_id, "deleted=1")
|
||||
.await?
|
||||
@@ -615,13 +610,7 @@ impl Imap {
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Download only the messages which have reached their target folder if there are
|
||||
// multiple devices. This prevents race conditions in multidevice case, where one
|
||||
// device tries to download the message while another device moves the message at the
|
||||
// same time. Even in single device case it is possible to fail downloading the first
|
||||
// message, move it to the movebox and then download the second message before
|
||||
// downloading the first one, if downloading from inbox before moving is allowed.
|
||||
if folder == target
|
||||
if !delete
|
||||
&& prefetch_should_download(context, &headers, &message_id, fetch_response.flags())
|
||||
.await
|
||||
.context("prefetch_should_download")?
|
||||
@@ -858,75 +847,10 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Moves batch of messages identified by their UID from the currently
|
||||
/// selected folder to the target folder.
|
||||
async fn move_message_batch(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
set: &str,
|
||||
row_ids: Vec<i64>,
|
||||
target: &str,
|
||||
) -> Result<()> {
|
||||
if self.can_move() {
|
||||
match self.uid_mv(set, &target).await {
|
||||
Ok(()) => {
|
||||
// Messages are moved or don't exist, IMAP returns OK response in both cases.
|
||||
context
|
||||
.sql
|
||||
.transaction(|transaction| {
|
||||
let mut stmt = transaction.prepare("DELETE FROM imap WHERE id = ?")?;
|
||||
for row_id in row_ids {
|
||||
stmt.execute((row_id,))?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.context("Cannot delete moved messages from imap table")?;
|
||||
context.emit_event(EventType::ImapMessageMoved(format!(
|
||||
"IMAP messages {set} moved to {target}"
|
||||
)));
|
||||
return Ok(());
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
context,
|
||||
"Cannot move messages, fallback to COPY/DELETE {} to {}: {}",
|
||||
set,
|
||||
target,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Server does not support MOVE or MOVE failed.
|
||||
// Copy messages to the destination folder if needed and mark records for deletion.
|
||||
info!(
|
||||
context,
|
||||
"Server does not support MOVE, fallback to COPY/DELETE {} to {}", set, target
|
||||
);
|
||||
self.uid_copy(&set, &target).await?;
|
||||
context
|
||||
.sql
|
||||
.transaction(|transaction| {
|
||||
let mut stmt = transaction.prepare("UPDATE imap SET target='' WHERE id = ?")?;
|
||||
for row_id in row_ids {
|
||||
stmt.execute((row_id,))?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.context("Cannot plan deletion of messages")?;
|
||||
context.emit_event(EventType::ImapMessageMoved(format!(
|
||||
"IMAP messages {set} copied to {target}"
|
||||
)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Moves and deletes messages as planned in the `imap` table.
|
||||
/// Deletes messages as planned in the `imap` table.
|
||||
///
|
||||
/// This is the only place where messages are moved or deleted on the IMAP server.
|
||||
async fn move_delete_messages(&mut self, context: &Context, folder: &str) -> Result<()> {
|
||||
/// This is the only place where messages are deleted on the IMAP server.
|
||||
async fn delete_messages(&mut self, context: &Context, folder: &str) -> Result<()> {
|
||||
let transport_id = self.transport_id();
|
||||
let rows = context
|
||||
.sql
|
||||
@@ -948,23 +872,21 @@ impl Session {
|
||||
|
||||
for (target, rowid_set, uid_set) in UidGrouper::from(rows) {
|
||||
// Select folder inside the loop to avoid selecting it if there are no pending
|
||||
// MOVE/DELETE operations. This does not result in multiple SELECT commands
|
||||
// DELETE operations. This does not result in multiple SELECT commands
|
||||
// being sent because `select_folder()` does nothing if the folder is already
|
||||
// selected.
|
||||
let folder_exists = self.select_with_uidvalidity(context, folder).await?;
|
||||
ensure!(folder_exists, "No folder {folder}");
|
||||
|
||||
// Empty target folder name means messages should be deleted.
|
||||
// Since we don't move messages between IMAP folders anymore,
|
||||
// `target` is always either empty or equal to `folder`.
|
||||
debug_assert!(target.is_empty() || folder == target);
|
||||
|
||||
if target.is_empty() {
|
||||
self.delete_message_batch(context, &uid_set, rowid_set)
|
||||
.await
|
||||
.with_context(|| format!("cannot delete batch of messages {uid_set:?}"))?;
|
||||
} else {
|
||||
self.move_message_batch(context, &uid_set, rowid_set, &target)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("cannot move batch of messages {uid_set:?} to folder {target:?}",)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1384,6 +1306,8 @@ impl Session {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let supports_push =
|
||||
max_smtp_rcpt_to.is_some() || iroh_relay.is_some() || ice_servers.is_some();
|
||||
let ice_servers = if let Some(ice_servers) = ice_servers {
|
||||
ice_servers
|
||||
} else {
|
||||
@@ -1399,7 +1323,7 @@ impl Session {
|
||||
admin,
|
||||
iroh_relay,
|
||||
max_smtp_rcpt_to,
|
||||
supports_push: max_smtp_rcpt_to.is_some() || self.capabilities.has_xdeltapush,
|
||||
supports_push,
|
||||
ice_servers,
|
||||
ice_servers_expiration_timestamp,
|
||||
app_versions,
|
||||
|
||||
@@ -9,10 +9,6 @@ pub(crate) struct Capabilities {
|
||||
/// <https://tools.ietf.org/html/rfc2177>
|
||||
pub can_idle: bool,
|
||||
|
||||
/// True if the server has MOVE capability as defined in
|
||||
/// <https://tools.ietf.org/html/rfc6851>
|
||||
pub can_move: bool,
|
||||
|
||||
/// True if the server has QUOTA capability as defined in
|
||||
/// <https://tools.ietf.org/html/rfc2087>
|
||||
pub can_check_quota: bool,
|
||||
@@ -25,17 +21,6 @@ pub(crate) struct Capabilities {
|
||||
/// <https://tools.ietf.org/html/rfc4978>
|
||||
pub can_compress: bool,
|
||||
|
||||
/// True if the server advertises the legacy `XDELTAPUSH` capability.
|
||||
pub has_xdeltapush: bool,
|
||||
|
||||
/// True if the server has an XCHATMAIL capability
|
||||
/// indicating that it is a <https://github.com/deltachat/chatmail> server.
|
||||
///
|
||||
/// This can be used to hide some advanced settings in the UI
|
||||
/// that are only interesting for normal email accounts,
|
||||
/// e.g. the ability to move messages to Delta Chat folder.
|
||||
pub is_chatmail: bool,
|
||||
|
||||
/// Server ID if the server supports ID capability.
|
||||
pub server_id: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
@@ -78,12 +78,9 @@ pub(crate) async fn identify_server(
|
||||
};
|
||||
let capabilities = Capabilities {
|
||||
can_idle: caps.has_str("IDLE"),
|
||||
can_move: caps.has_str("MOVE"),
|
||||
can_check_quota: caps.has_str("QUOTA"),
|
||||
can_metadata: caps.has_str("METADATA"),
|
||||
can_compress: caps.has_str("COMPRESS=DEFLATE"),
|
||||
has_xdeltapush: caps.has_str("XDELTAPUSH"),
|
||||
is_chatmail: caps.has_str("XCHATMAIL"),
|
||||
server_id,
|
||||
};
|
||||
Ok(capabilities)
|
||||
|
||||
@@ -99,10 +99,6 @@ impl Session {
|
||||
self.capabilities.can_idle
|
||||
}
|
||||
|
||||
pub fn can_move(&self) -> bool {
|
||||
self.capabilities.can_move
|
||||
}
|
||||
|
||||
pub fn can_check_quota(&self) -> bool {
|
||||
self.capabilities.can_check_quota
|
||||
}
|
||||
@@ -111,11 +107,6 @@ impl Session {
|
||||
self.capabilities.can_metadata
|
||||
}
|
||||
|
||||
// Returns true if IMAP server has `XCHATMAIL` capability.
|
||||
pub(crate) fn is_chatmail(&self) -> bool {
|
||||
self.capabilities.is_chatmail
|
||||
}
|
||||
|
||||
/// Prefetch `n_uids` messages starting from `uid_next`. Returns a list of fetch results in the
|
||||
/// order of ascending UIDs.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
|
||||
@@ -981,7 +981,6 @@ mod tests {
|
||||
context1.get_config(Config::BccSelf).await?,
|
||||
Some("0".to_string())
|
||||
);
|
||||
context1.set_config_bool(Config::IsChatmail, true).await?;
|
||||
|
||||
assert_eq!(context1.get_config_bool(Config::IsMuted).await?, false);
|
||||
context1.set_config_bool(Config::IsMuted, true).await?;
|
||||
@@ -1001,7 +1000,6 @@ mod tests {
|
||||
.get_matching(|evt| matches!(evt, EventType::ImexProgress(1000)))
|
||||
.await;
|
||||
assert!(context2.is_configured().await?);
|
||||
assert!(context2.is_chatmail().await?);
|
||||
for ctx in [context1, context2] {
|
||||
// BccSelf should be enabled automatically when exporting a backup
|
||||
assert_eq!(ctx.get_config_bool(Config::BccSelf).await?, true);
|
||||
|
||||
@@ -662,6 +662,7 @@ mod tests {
|
||||
use crate::config::Config;
|
||||
use crate::test_utils::{TestContext, TestContextManager, alice_keypair};
|
||||
use crate::tools::SystemTime;
|
||||
use crate::transport::add_pseudo_transport;
|
||||
|
||||
static KEYPAIR: LazyLock<SignedSecretKey> = LazyLock::new(alice_keypair);
|
||||
|
||||
@@ -811,6 +812,7 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_load_self_generate_public() {
|
||||
let t = TestContext::new().await;
|
||||
add_pseudo_transport(&t, "alice@example.org").await.unwrap();
|
||||
t.set_config(Config::ConfiguredAddr, Some("alice@example.org"))
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -821,6 +823,7 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_load_self_generate_secret() {
|
||||
let t = TestContext::new().await;
|
||||
add_pseudo_transport(&t, "alice@example.org").await.unwrap();
|
||||
t.set_config(Config::ConfiguredAddr, Some("alice@example.org"))
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -833,6 +836,7 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
|
||||
use std::thread;
|
||||
|
||||
let t = TestContext::new().await;
|
||||
add_pseudo_transport(&t, "alice@example.org").await.unwrap();
|
||||
t.set_config(Config::ConfiguredAddr, Some("alice@example.org"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ pub mod stock_str;
|
||||
pub mod storage_usage;
|
||||
mod sync;
|
||||
mod token;
|
||||
mod transport;
|
||||
pub mod transport;
|
||||
mod update_helper;
|
||||
pub mod webxdc;
|
||||
#[macro_use]
|
||||
|
||||
+9
-12
@@ -1445,13 +1445,10 @@ impl std::fmt::Display for MessageState {
|
||||
}
|
||||
|
||||
impl MessageState {
|
||||
/// Returns true if the message can transition to `OutFailed` state from the current state.
|
||||
/// Returns true if the message can be marked as failed in the current state.
|
||||
pub fn can_fail(self) -> bool {
|
||||
use MessageState::*;
|
||||
matches!(
|
||||
self,
|
||||
OutPending | OutDelivered | OutMdnRcvd // OutMdnRcvd can still fail because it could be a group message and only some recipients failed.
|
||||
)
|
||||
matches!(self, OutPending | OutDelivered | OutFailed)
|
||||
}
|
||||
|
||||
/// Returns true for any outgoing message states.
|
||||
@@ -1975,15 +1972,15 @@ pub(crate) async fn set_msg_failed(
|
||||
msg: &mut Message,
|
||||
error: &str,
|
||||
) -> Result<()> {
|
||||
if msg.state.can_fail() {
|
||||
msg.state = MessageState::OutFailed;
|
||||
warn!(context, "{} failed: {}", msg.id, error);
|
||||
} else {
|
||||
warn!(
|
||||
if !msg.state.can_fail() {
|
||||
info!(
|
||||
context,
|
||||
"{} seems to have failed ({}), but state is {}", msg.id, error, msg.state
|
||||
)
|
||||
"Ignoring failed {} in state {}: {}", msg.id, msg.state, error
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
msg.state = MessageState::OutFailed;
|
||||
warn!(context, "{} failed: {}", msg.id, error);
|
||||
msg.error = Some(error.to_string());
|
||||
|
||||
let exists = context
|
||||
|
||||
@@ -454,6 +454,28 @@ async fn test_get_state() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that a failure reported after a read receipt leaves the message untouched.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_set_msg_failed_after_mdn() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
let alice_chat = alice.create_chat(bob).await;
|
||||
let sent = alice.send_text(alice_chat.id, "hi").await;
|
||||
let bob_msg = bob.recv_msg(&sent).await;
|
||||
alice.recv_mdn(bob, &bob_msg).await?;
|
||||
let mut msg = sent.load_from_db().await;
|
||||
assert_eq!(msg.state, MessageState::OutMdnRcvd);
|
||||
|
||||
set_msg_failed(alice, &mut msg, "relay bounced").await?;
|
||||
let msg = sent.load_from_db().await;
|
||||
assert_eq!(msg.state, MessageState::OutMdnRcvd);
|
||||
assert_eq!(msg.error(), None);
|
||||
let chats = Chatlist::try_load(alice, 0, None, None).await?;
|
||||
assert_eq!(chats.get_msg_id(0)?, Some(msg.id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_is_bot() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
@@ -631,6 +653,10 @@ async fn test_delete_msgs_offline() -> Result<()> {
|
||||
delete_msgs(alice, &[msg.id]).await?;
|
||||
assert!(!alice.sql.exists(stmt, (msg.id,)).await?);
|
||||
|
||||
alice
|
||||
.assert_warn("No SMTP connection candidates provided")
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -756,3 +782,23 @@ async fn test_get_existing_msg_ids() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_can_fail() -> Result<()> {
|
||||
use MessageState::*;
|
||||
|
||||
// states that are not allowed to transition to OutFailed
|
||||
assert!(!Undefined.can_fail());
|
||||
assert!(!InFresh.can_fail());
|
||||
assert!(!InNoticed.can_fail());
|
||||
assert!(!InSeen.can_fail());
|
||||
assert!(!OutDraft.can_fail());
|
||||
assert!(!OutMdnRcvd.can_fail());
|
||||
|
||||
// states that are allowed to transition to OutFailed
|
||||
assert!(OutPending.can_fail());
|
||||
assert!(OutDelivered.can_fail());
|
||||
assert!(OutFailed.can_fail());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+10
-107
@@ -17,7 +17,7 @@ use tokio::fs;
|
||||
|
||||
use crate::aheader::{Aheader, EncryptPreference};
|
||||
use crate::blob::BlobObject;
|
||||
use crate::chat::{self, Chat, ChatId, PARAM_BROADCAST_SECRET, load_broadcast_secret};
|
||||
use crate::chat::{self, Chat, PARAM_BROADCAST_SECRET, load_broadcast_secret};
|
||||
use crate::config::Config;
|
||||
use crate::constants::{Chattype, DC_FROM_HANDSHAKE};
|
||||
use crate::contact::{Contact, ContactId, Origin};
|
||||
@@ -36,6 +36,9 @@ use crate::param::Param;
|
||||
use crate::peer_channels::{create_iroh_header, get_iroh_topic_for_msg};
|
||||
use crate::pgp::{SeipdVersion, addresses_from_public_key, pubkey_supports_seipdv2, relay_addrs};
|
||||
use crate::simplify::escape_message_footer_marks;
|
||||
use crate::smtp::queue::{
|
||||
Encryption as QueuedEncryption, QueuedMail, SideEffects as QueueSideEffects, ToBeQueuedMail,
|
||||
};
|
||||
use crate::stock_str;
|
||||
use crate::tools::{IsNoneOrEmpty, create_outgoing_rfc724_mid, remove_subject_prefix, time};
|
||||
use crate::webxdc::StatusUpdateSerial;
|
||||
@@ -221,110 +224,6 @@ pub struct RenderedMessage {
|
||||
sync_ids_to_delete: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum QueuedEncryption {
|
||||
/// Unencrypted message.
|
||||
No,
|
||||
|
||||
/// The message is encrypted asymmetrically to public keys.
|
||||
Asymmetric {
|
||||
/// OpenPGP keys to use for encryption.
|
||||
///
|
||||
/// The message is always encrypted to self,
|
||||
/// no need to include own key here.
|
||||
encryption_pubkeys: Vec<SignedPublicKey>,
|
||||
},
|
||||
|
||||
/// Symmetrically encrypted message with a shared secret.
|
||||
Symmetric { shared_secret: String },
|
||||
}
|
||||
|
||||
impl QueuedEncryption {
|
||||
pub(crate) fn is_encrypted(&self) -> bool {
|
||||
match self {
|
||||
Self::No => false,
|
||||
Self::Asymmetric { .. } => true,
|
||||
Self::Symmetric { .. } => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Email message queued, but not sent yet.
|
||||
///
|
||||
/// It is stored unencrypted to
|
||||
/// make it possible to change protected headers
|
||||
/// like the From address and Autocrypt header later.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct QueuedMail {
|
||||
/// Unencrypted queued message.
|
||||
///
|
||||
/// This message has both the headers and the body,
|
||||
/// but without the From, Autocrypt and Message-ID headers.
|
||||
///
|
||||
/// For encrypted messages this is the OpenPGP payload.
|
||||
pub(crate) raw_message: Vec<u8>,
|
||||
|
||||
/// Display name to put in the `From:` field.
|
||||
///
|
||||
/// Email address is not determined yet here.
|
||||
pub(crate) display_name: String,
|
||||
|
||||
/// Message-ID.
|
||||
pub(crate) rfc724_mid: String,
|
||||
|
||||
/// Whether the message is encrypted and encryption keys.
|
||||
pub(crate) encryption: QueuedEncryption,
|
||||
|
||||
/// If true, Autocrypt header should be added before sending.
|
||||
pub(crate) should_attach_pubkey: bool,
|
||||
|
||||
/// If true, OpenPGP compression may be used.
|
||||
pub(crate) should_compress: bool,
|
||||
|
||||
/// If true, encrypted message should be signed.
|
||||
pub(crate) should_sign: bool,
|
||||
|
||||
/// Recipient addresses.
|
||||
pub(crate) recipients: Vec<String>,
|
||||
|
||||
/// Addresses the messages was already sent to.
|
||||
pub(crate) sent_to: Vec<String>,
|
||||
|
||||
/// If true, own addresses should be added to the list of recipients.
|
||||
///
|
||||
/// For unencrypted messages, only the sending addresses should be added.
|
||||
/// For encrypted messages, all published addresses should be added.
|
||||
pub(crate) bcc_self: bool,
|
||||
}
|
||||
|
||||
/// Side effects that should be applied at the same time
|
||||
/// as the message is persisted in the queue.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct QueueSideEffects {
|
||||
/// ID of the chat side effects should be applied to.
|
||||
pub chat_id: ChatId,
|
||||
|
||||
/// Largest timestamp of the location sent in `location.kml` in this message.
|
||||
pub last_added_location_timestamp: Option<i64>,
|
||||
|
||||
/// True if the message has the avatar attached.
|
||||
///
|
||||
/// Timestamp of the last time avatar was gossiped should be updated.
|
||||
pub avatar_is_attached: bool,
|
||||
|
||||
/// A comma-separated string of sync-IDs that are used by the rendered email and must be deleted
|
||||
/// from `multi_device_sync` once the message is actually queued for sending.
|
||||
pub sync_ids_to_delete: Option<String>,
|
||||
|
||||
/// Subject that was rendered into the message.
|
||||
///
|
||||
/// Used to update the subject on the sent message object.
|
||||
pub subject: String,
|
||||
}
|
||||
|
||||
/// Email message ready to be queued with the side effects that should be applied at the same time.
|
||||
pub(crate) type ToBeQueuedMail = (QueuedMail, Option<QueueSideEffects>);
|
||||
|
||||
/// Renders [`QueuedMail`].
|
||||
///
|
||||
/// Adds headers:
|
||||
@@ -548,6 +447,7 @@ pub(crate) fn render_queued_mail(
|
||||
}
|
||||
|
||||
/// Renders queued mail with the current sending address.
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn render_queued_mail_with_context(
|
||||
queued_mail: QueuedMail,
|
||||
context: &Context,
|
||||
@@ -1420,13 +1320,16 @@ impl MimeFactory {
|
||||
///
|
||||
/// Used for MDNs because they are fully rendered and sent in one go,
|
||||
/// rather than first creating a [`QueuedMail`] and sending it later.
|
||||
pub async fn render(self, context: &Context) -> Result<RenderedEmail> {
|
||||
pub async fn render(self, context: &Context, from_addr: &str) -> Result<RenderedEmail> {
|
||||
// Does not matter, we are not going to return the QueuedMail.
|
||||
let bcc_self = false;
|
||||
let public_key = key::load_self_public_key(context).await?;
|
||||
let secret_key = key::load_self_secret_key(context).await?;
|
||||
|
||||
let (queued_mail, _side_effects) =
|
||||
Box::pin(self.into_queued_mail(context, bcc_self)).await?;
|
||||
let rendered_mail = render_queued_mail_with_context(queued_mail, context).await?;
|
||||
let rendered_mail =
|
||||
render_queued_mail(queued_mail, &public_key, &secret_key, from_addr.to_string())?;
|
||||
Ok(rendered_mail)
|
||||
}
|
||||
|
||||
|
||||
@@ -307,7 +307,8 @@ async fn test_mdn_create_encrypted() -> Result<()> {
|
||||
let mimefactory =
|
||||
MimeFactory::from_mdn(&bob, rcvd.from_id, rcvd.rfc724_mid.clone(), vec![]).await?;
|
||||
assert!(!mimefactory.will_be_encrypted());
|
||||
let rendered_msg = mimefactory.render(&bob).await?;
|
||||
let bob_addr = bob.get_primary_self_addr().await?;
|
||||
let rendered_msg = mimefactory.render(&bob, &bob_addr).await?;
|
||||
|
||||
assert!(!rendered_msg.message.contains("Bob Examplenet"));
|
||||
assert!(!rendered_msg.message.contains("Alice Exampleorg"));
|
||||
@@ -320,7 +321,8 @@ async fn test_mdn_create_encrypted() -> Result<()> {
|
||||
|
||||
let mimefactory = MimeFactory::from_mdn(&bob, rcvd.from_id, rcvd.rfc724_mid, vec![]).await?;
|
||||
assert!(mimefactory.will_be_encrypted());
|
||||
let rendered_msg = mimefactory.render(&bob).await?;
|
||||
let bob_addr = bob.get_primary_self_addr().await?;
|
||||
let rendered_msg = mimefactory.render(&bob, &bob_addr).await?;
|
||||
|
||||
assert!(!rendered_msg.message.contains("Bob Examplenet"));
|
||||
assert!(!rendered_msg.message.contains("Alice Exampleorg"));
|
||||
@@ -363,7 +365,8 @@ async fn test_mdn_autocrypt_throttle() -> Result<()> {
|
||||
rcvd: &Message,
|
||||
) -> Result<bool> {
|
||||
let mf = MimeFactory::from_mdn(bob, rcvd.from_id, rcvd.rfc724_mid.clone(), vec![]).await?;
|
||||
let rendered_msg = mf.render(bob).await?;
|
||||
let addr = bob.get_primary_self_addr().await?;
|
||||
let rendered_msg = mf.render(bob, &addr).await?;
|
||||
let mime = MimeMessage::from_bytes(alice, rendered_msg.message.as_bytes()).await?;
|
||||
Ok(mime.autocrypt_fingerprint.is_some())
|
||||
}
|
||||
@@ -644,7 +647,8 @@ async fn test_render_reply() {
|
||||
let recipients = mimefactory.recipients();
|
||||
assert_eq!(recipients, vec!["charlie@example.net"]);
|
||||
|
||||
let rendered_msg = mimefactory.render(t).await.unwrap();
|
||||
let addr = t.get_primary_self_addr().await.unwrap();
|
||||
let rendered_msg = mimefactory.render(t, &addr).await.unwrap();
|
||||
|
||||
let mail = mailparse::parse_mail(rendered_msg.message.as_bytes()).unwrap();
|
||||
assert_eq!(
|
||||
|
||||
+11
-12
@@ -2540,19 +2540,18 @@ async fn handle_ndn(
|
||||
for msg_id in msg_ids {
|
||||
let mut message = Message::load_from_db(context, msg_id).await?;
|
||||
let chat = Chat::load_from_db(context, message.chat_id).await?;
|
||||
if chat.typ == constants::Chattype::OutBroadcast {
|
||||
continue;
|
||||
if chat.typ == constants::Chattype::Single {
|
||||
let aggregated_error = message
|
||||
.error
|
||||
.as_ref()
|
||||
.map(|err| format!("{err}\n\n{err_msg}"));
|
||||
set_msg_failed(
|
||||
context,
|
||||
&mut message,
|
||||
aggregated_error.as_ref().unwrap_or(err_msg),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let aggregated_error = message
|
||||
.error
|
||||
.as_ref()
|
||||
.map(|err| format!("{err}\n\n{err_msg}"));
|
||||
set_msg_failed(
|
||||
context,
|
||||
&mut message,
|
||||
aggregated_error.as_ref().unwrap_or(err_msg),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::sync::LazyLock;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
use super::*;
|
||||
use crate::transport::add_pseudo_transport;
|
||||
use crate::{
|
||||
config::Config,
|
||||
decrypt,
|
||||
@@ -19,6 +20,9 @@ async fn decrypt_bytes(
|
||||
auth_tokens_for_decryption: &[String],
|
||||
) -> Result<pgp::composed::Message<'static>> {
|
||||
let t = &TestContext::new().await;
|
||||
add_pseudo_transport(t, "alice@example.org")
|
||||
.await
|
||||
.expect("Failed to add pseudo transport");
|
||||
t.set_config(Config::ConfiguredAddr, Some("alice@example.org"))
|
||||
.await
|
||||
.expect("Failed to configure address");
|
||||
|
||||
@@ -13,6 +13,7 @@ use serde::Deserialize;
|
||||
|
||||
use crate::autorelay::login_param_from_host;
|
||||
use crate::config::Config;
|
||||
use crate::configure::MAX_RELAYS;
|
||||
use crate::contact::{Contact, ContactId, Origin};
|
||||
use crate::context::Context;
|
||||
use crate::key::Fingerprint;
|
||||
@@ -133,18 +134,6 @@ pub enum Qr {
|
||||
contact_id: ContactId,
|
||||
},
|
||||
|
||||
/// Scanned fingerprint does not match the last seen fingerprint.
|
||||
FprMismatch {
|
||||
/// Contact ID.
|
||||
contact_id: Option<ContactId>,
|
||||
},
|
||||
|
||||
/// The scanned QR code contains a fingerprint but no e-mail address.
|
||||
FprWithoutAddr {
|
||||
/// Key fingerprint.
|
||||
fingerprint: String,
|
||||
},
|
||||
|
||||
/// Ask the user if they want to create an account on the given domain.
|
||||
Account {
|
||||
/// Server domain name.
|
||||
@@ -500,7 +489,8 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
addrs.push(normalize_address(primary_addr)?);
|
||||
};
|
||||
if let Some(secondary_addrs_raw) = param.get("r") {
|
||||
for secondary_address in secondary_addrs_raw.split(',') {
|
||||
let max_secondary = MAX_RELAYS.saturating_sub(addrs.len());
|
||||
for secondary_address in secondary_addrs_raw.split(',').take(max_secondary) {
|
||||
addrs.push(normalize_address(secondary_address)?)
|
||||
}
|
||||
}
|
||||
@@ -651,24 +641,21 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
is_v3,
|
||||
})
|
||||
}
|
||||
} else if let Some(addr) = addrs.first() {
|
||||
let fingerprint = fingerprint.hex();
|
||||
let (contact_id, _) =
|
||||
Contact::add_or_lookup_ext(context, "", addr, &fingerprint, Origin::UnhandledQrScan)
|
||||
.await?;
|
||||
let contact = Contact::get_by_id(context, contact_id).await?;
|
||||
|
||||
if contact.public_key(context).await?.is_some() {
|
||||
Ok(Qr::FprOk { contact_id })
|
||||
} else {
|
||||
Ok(Qr::FprMismatch {
|
||||
contact_id: Some(contact_id),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
Ok(Qr::FprWithoutAddr {
|
||||
fingerprint: fingerprint.human_readable(),
|
||||
})
|
||||
let fingerprint = fingerprint.hex();
|
||||
let contact_id: Option<ContactId> = context
|
||||
.sql
|
||||
.query_get_value(
|
||||
"SELECT id FROM contacts WHERE fingerprint=?",
|
||||
(fingerprint,),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(contact_id) = contact_id else {
|
||||
bail!("Contact matching the fingerprint is not found");
|
||||
};
|
||||
|
||||
Ok(Qr::FprOk { contact_id })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -838,7 +825,8 @@ pub(crate) async fn login_param_from_account_qr(
|
||||
.context("Invalid DCACCOUNT scheme")?;
|
||||
|
||||
if !payload.starts_with(HTTPS_SCHEME) {
|
||||
let param = login_param_from_host(payload);
|
||||
let mark_as_autorelay = false;
|
||||
let param = login_param_from_host(payload, mark_as_autorelay);
|
||||
return Ok(param);
|
||||
}
|
||||
|
||||
|
||||
+52
-38
@@ -305,12 +305,10 @@ async fn test_decode_openpgp_invalid_token() -> Result<()> {
|
||||
let ctx = TestContext::new_alice().await;
|
||||
|
||||
// Token cannot contain "/"
|
||||
let qr = check_qr(
|
||||
assert!(check_qr(
|
||||
&ctx.ctx,
|
||||
"OPENPGP4FPR:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL/cxRL"
|
||||
).await?;
|
||||
|
||||
assert!(matches!(qr, Qr::FprMismatch { .. }));
|
||||
).await.is_err());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -361,6 +359,23 @@ async fn test_decode_openpgp_secure_join() -> Result<()> {
|
||||
bail!("Wrong QR code type");
|
||||
}
|
||||
|
||||
// A bad invite code must not be able to steer us onto arbitrarily many relays.
|
||||
let relays: Vec<String> = (0..20).map(|i| format!("cli%40r{i}.example.org")).collect();
|
||||
let qr = check_qr(
|
||||
&ctx.ctx,
|
||||
&format!(
|
||||
"openpgp4fpr:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&r={}&i=TbnwJ6lSvD5&s=0ejvbdFSQxB",
|
||||
relays.join(",")
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Qr::AskVerifyContact { addrs, .. } = qr {
|
||||
assert_eq!(addrs.len(), MAX_RELAYS);
|
||||
} else {
|
||||
bail!("Wrong QR code type");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -373,16 +388,17 @@ async fn test_decode_openpgp_fingerprint() -> Result<()> {
|
||||
let alice_contact = bob.add_or_lookup_contact(alice).await;
|
||||
let alice_contact_id = alice_contact.id;
|
||||
|
||||
let qr = check_qr(
|
||||
bob,
|
||||
"OPENPGP4FPR:1234567890123456789012345678901234567890#a=alice@example.org",
|
||||
)
|
||||
.await?;
|
||||
if let Qr::FprMismatch { contact_id, .. } = qr {
|
||||
assert_ne!(contact_id.unwrap(), alice_contact_id);
|
||||
} else {
|
||||
bail!("Wrong QR code type");
|
||||
}
|
||||
// OPENPGP4FPR may have an address,
|
||||
// but it is not used anymore since key contacts are introduced.
|
||||
// We lookup the contact only by fingerprint and ignore the address.
|
||||
assert!(
|
||||
check_qr(
|
||||
bob,
|
||||
"OPENPGP4FPR:1234567890123456789012345678901234567890#a=alice@example.org",
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let qr = check_qr(
|
||||
bob,
|
||||
@@ -398,46 +414,44 @@ async fn test_decode_openpgp_fingerprint() -> Result<()> {
|
||||
bail!("Wrong QR code type");
|
||||
}
|
||||
|
||||
assert!(matches!(
|
||||
assert!(
|
||||
check_qr(
|
||||
bob,
|
||||
"OPENPGP4FPR:1234567890123456789012345678901234567890#a=bob@example.org",
|
||||
)
|
||||
.await?,
|
||||
Qr::FprMismatch { .. }
|
||||
));
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests OPENPGP4FPR QR codes without an email address.
|
||||
///
|
||||
/// Email address in OPENPGP4FPR QR codes was an extension
|
||||
/// used before switch to identifying contacts by fingerprint.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_decode_openpgp_without_addr() -> Result<()> {
|
||||
let ctx = TestContext::new().await;
|
||||
|
||||
let qr = check_qr(
|
||||
&ctx.ctx,
|
||||
"OPENPGP4FPR:1234567890123456789012345678901234567890",
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
qr,
|
||||
Qr::FprWithoutAddr {
|
||||
fingerprint: "1234 5678 9012 3456 7890\n1234 5678 9012 3456 7890".to_string()
|
||||
}
|
||||
assert!(
|
||||
check_qr(
|
||||
&ctx.ctx,
|
||||
"OPENPGP4FPR:1234567890123456789012345678901234567890",
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
// Test it again with lowercased "openpgp4fpr:" uri scheme
|
||||
|
||||
let qr = check_qr(
|
||||
&ctx.ctx,
|
||||
"openpgp4fpr:1234567890123456789012345678901234567890",
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
qr,
|
||||
Qr::FprWithoutAddr {
|
||||
fingerprint: "1234 5678 9012 3456 7890\n1234 5678 9012 3456 7890".to_string()
|
||||
}
|
||||
assert!(
|
||||
check_qr(
|
||||
&ctx.ctx,
|
||||
"openpgp4fpr:1234567890123456789012345678901234567890",
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let res = check_qr(&ctx.ctx, "OPENPGP4FPR:12345678901234567890").await;
|
||||
|
||||
+5
-10
@@ -2107,7 +2107,8 @@ async fn add_parts(
|
||||
let hidden = part.is_reaction;
|
||||
if part.is_reaction {
|
||||
let reaction_str = simplify::remove_footers(part.msg.as_str());
|
||||
let is_incoming_fresh = mime_parser.incoming && !seen;
|
||||
let is_incoming_fresh =
|
||||
mime_parser.incoming && !seen && chat_id_blocked == Blocked::Not;
|
||||
set_msg_reaction(
|
||||
context,
|
||||
mime_in_reply_to,
|
||||
@@ -3732,10 +3733,9 @@ async fn apply_out_broadcast_changes(
|
||||
if from_id == ContactId::SELF {
|
||||
let added_id = lookup_key_contact_by_fingerprint(context, added_fpr).await?;
|
||||
if let Some(added_id) = added_id {
|
||||
if chat::is_contact_in_chat(context, chat.id, added_id).await? {
|
||||
info!(context, "No-op broadcast addition (TRASH)");
|
||||
better_msg.get_or_insert("".to_string());
|
||||
} else {
|
||||
info!(context, "Broadcast addition (TRASH)");
|
||||
better_msg.get_or_insert("".to_string());
|
||||
if !chat::is_contact_in_chat(context, chat.id, added_id).await? {
|
||||
chat::add_to_chat_contacts_table(
|
||||
context,
|
||||
mime_parser.timestamp_sent,
|
||||
@@ -3743,11 +3743,6 @@ async fn apply_out_broadcast_changes(
|
||||
&[added_id],
|
||||
)
|
||||
.await?;
|
||||
let msg =
|
||||
stock_str::msg_add_member_local(context, added_id, ContactId::UNDEFINED)
|
||||
.await;
|
||||
better_msg.get_or_insert(msg);
|
||||
added_removed_id = Some(added_id);
|
||||
send_event_chat_modified = true;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -726,6 +726,7 @@ async fn test_resend_after_ndn() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// an NDN in a group does not make the whole message as failed
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_parse_ndn_group_msg() -> Result<()> {
|
||||
let t = TestContext::new().await;
|
||||
@@ -758,7 +759,7 @@ async fn test_parse_ndn_group_msg() -> Result<()> {
|
||||
|
||||
let msg = Message::load_from_db(&t, msg_id).await?;
|
||||
|
||||
assert_eq!(msg.state, MessageState::OutFailed);
|
||||
assert_eq!(msg.state, MessageState::OutDelivered);
|
||||
|
||||
let msgs = chat::get_chat_msgs(&t, msg.chat_id).await?;
|
||||
assert!(matches!(
|
||||
@@ -766,9 +767,6 @@ async fn test_parse_ndn_group_msg() -> Result<()> {
|
||||
ChatItem::Message { msg_id } if msg_id == msg.id
|
||||
));
|
||||
|
||||
t.assert_warn("Delivery Status Notification (Failure)")
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2725,19 +2723,7 @@ async fn test_read_receipts_dont_create_chats() -> Result<()> {
|
||||
let chats = Chatlist::try_load(&alice, 0, None, None).await?;
|
||||
assert_eq!(chats.len(), 0);
|
||||
|
||||
// Bob sends a read receipt.
|
||||
let mdn_mimefactory = crate::mimefactory::MimeFactory::from_mdn(
|
||||
&bob,
|
||||
received_msg.from_id,
|
||||
received_msg.rfc724_mid,
|
||||
vec![],
|
||||
)
|
||||
.await?;
|
||||
let rendered_mdn = mdn_mimefactory.render(&bob).await?;
|
||||
let mdn_body = rendered_mdn.message;
|
||||
|
||||
// Alice receives the read receipt.
|
||||
receive_imf(&alice, mdn_body.as_bytes(), false).await?;
|
||||
alice.recv_mdn(&bob, &received_msg).await?;
|
||||
|
||||
// Chat should not pop up in the chatlist.
|
||||
let chats = Chatlist::try_load(&alice, 0, None, None).await?;
|
||||
@@ -2760,19 +2746,7 @@ async fn test_read_receipts_dont_unmark_bots() -> Result<()> {
|
||||
.await;
|
||||
let received_msg = bob.get_last_msg().await;
|
||||
|
||||
// Bob sends a read receipt.
|
||||
let mdn_mimefactory = crate::mimefactory::MimeFactory::from_mdn(
|
||||
bob,
|
||||
received_msg.from_id,
|
||||
received_msg.rfc724_mid,
|
||||
vec![],
|
||||
)
|
||||
.await?;
|
||||
let rendered_mdn = mdn_mimefactory.render(bob).await?;
|
||||
let mdn_body = rendered_mdn.message;
|
||||
|
||||
// Alice receives the read receipt.
|
||||
receive_imf(alice, mdn_body.as_bytes(), false).await?;
|
||||
alice.recv_mdn(bob, &received_msg).await?;
|
||||
let msg = alice.get_last_msg_in(alice_chat.id).await;
|
||||
assert_eq!(msg.state, MessageState::OutMdnRcvd);
|
||||
let ab_contact = alice.add_or_lookup_contact(bob).await;
|
||||
@@ -3364,6 +3338,35 @@ async fn test_blocked_contact_creates_group() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_blocked_contact_sends_reaction() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let bob_msg_id = tcm.send_recv_accept(alice, bob, "Hi!").await.id;
|
||||
|
||||
let chat = alice.get_chat(bob).await;
|
||||
chat.id.block(alice).await?;
|
||||
|
||||
crate::reaction::send_reaction(bob, bob_msg_id, "👍").await?;
|
||||
let sent = bob.pop_sent_msg().await;
|
||||
alice.recv_msg_hidden(&sent).await;
|
||||
alice.emit_event(EventType::Test);
|
||||
|
||||
while let Some(ev) = alice.evtracker.recv().await {
|
||||
match ev.typ {
|
||||
EventType::IncomingReaction { .. } => {
|
||||
panic!("Alice is not supposed to receive a notification, since she blocked Bob")
|
||||
}
|
||||
EventType::Test => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_outgoing_undecryptable() -> Result<()> {
|
||||
let alice = &TestContext::new().await;
|
||||
@@ -5763,7 +5766,7 @@ pub(crate) async fn first_row_in_smtp_queue(context: &TestContext) -> (MsgId, St
|
||||
let queued_mail = context
|
||||
.sql
|
||||
.transaction_ext(query_only, |transaction| {
|
||||
smtp::load_queued_mail(transaction, rowid)
|
||||
smtp::queue::load_queued_mail(transaction, rowid)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
+13
-12
@@ -215,10 +215,17 @@ impl SchedulerState {
|
||||
|
||||
/// Indicate that the network likely has come back.
|
||||
pub(crate) async fn maybe_network(&self) {
|
||||
self.interrupt_inbox_idle().await;
|
||||
self.interrupt_smtp().await;
|
||||
}
|
||||
|
||||
/// Interrupts IDLE on all transports so that they fetch,
|
||||
/// and marks them as having work to do.
|
||||
pub(crate) async fn interrupt_inbox_idle(&self) {
|
||||
let inner = self.inner.read().await;
|
||||
let inboxes = match *inner {
|
||||
InnerSchedulerState::Started(ref scheduler) => {
|
||||
scheduler.maybe_network();
|
||||
scheduler.interrupt_inbox();
|
||||
scheduler
|
||||
.inboxes
|
||||
.iter()
|
||||
@@ -334,7 +341,7 @@ async fn background_fetch_from_transport(
|
||||
|
||||
let folder = connection.folder.clone();
|
||||
connection
|
||||
.fetch_move_delete(context, &mut session, &folder)
|
||||
.fetch_delete(context, &mut session, &folder)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -514,7 +521,6 @@ async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session)
|
||||
};
|
||||
|
||||
maybe_broadcast_reactions(ctx).await.log_err(ctx).ok();
|
||||
maybe_send_stats(ctx).await.log_err(ctx).ok();
|
||||
|
||||
session
|
||||
.update_metadata(ctx)
|
||||
@@ -527,6 +533,8 @@ async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session)
|
||||
);
|
||||
}
|
||||
|
||||
maybe_send_stats(ctx).await.log_err(ctx).ok();
|
||||
|
||||
let session = fetch_idle(ctx, imap, session).await?;
|
||||
Ok(session)
|
||||
}
|
||||
@@ -548,9 +556,9 @@ async fn fetch_idle(ctx: &Context, connection: &mut Imap, mut session: Session)
|
||||
|
||||
// Fetch the watched folder.
|
||||
connection
|
||||
.fetch_move_delete(ctx, &mut session, &watch_folder)
|
||||
.fetch_delete(ctx, &mut session, &watch_folder)
|
||||
.await
|
||||
.context("fetch_move_delete")?;
|
||||
.context("fetch_delete")?;
|
||||
|
||||
download_known_post_messages_without_pre_message(ctx, &mut session).await?;
|
||||
download_msgs(ctx, &mut session)
|
||||
@@ -799,13 +807,6 @@ impl Scheduler {
|
||||
self.inboxes.iter()
|
||||
}
|
||||
|
||||
fn maybe_network(&self) {
|
||||
for b in self.boxes() {
|
||||
b.conn_state.interrupt();
|
||||
}
|
||||
self.interrupt_smtp();
|
||||
}
|
||||
|
||||
fn maybe_network_lost(&self) {
|
||||
for b in self.boxes() {
|
||||
b.conn_state.interrupt();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use core::fmt;
|
||||
use std::cmp::min;
|
||||
use std::{iter::once, ops::Deref, sync::Arc};
|
||||
use std::{ops::Deref, sync::Arc};
|
||||
|
||||
use anyhow::Result;
|
||||
use humansize::{BINARY, format_size};
|
||||
@@ -531,14 +531,15 @@ impl Context {
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
/// Returns true if all background work is done.
|
||||
async fn all_work_done(&self) -> bool {
|
||||
/// Returns true if all background work is done,
|
||||
/// checking the outgoing message queue only if `include_smtp` is set.
|
||||
async fn work_done(&self, include_smtp: bool) -> bool {
|
||||
let lock = self.scheduler.inner.read().await;
|
||||
let stores: Vec<_> = match *lock {
|
||||
InnerSchedulerState::Started(ref sched) => sched
|
||||
.boxes()
|
||||
.map(|b| &b.conn_state.state)
|
||||
.chain(once(&sched.smtp.state))
|
||||
.chain(include_smtp.then_some(&sched.smtp.state))
|
||||
.map(|state| state.connectivity.clone())
|
||||
.collect(),
|
||||
_ => return false,
|
||||
@@ -555,19 +556,26 @@ impl Context {
|
||||
|
||||
/// Waits until background work is finished.
|
||||
pub async fn wait_for_all_work_done(&self) {
|
||||
let include_smtp = true;
|
||||
self.wait_for_work_done(include_smtp).await
|
||||
}
|
||||
|
||||
/// Waits until background work is finished,
|
||||
/// checking the outgoing message queue only if `include_smtp` is set.
|
||||
pub(crate) async fn wait_for_work_done(&self, include_smtp: bool) {
|
||||
// Ideally we could wait for connectivity change events,
|
||||
// but sleep loop is good enough.
|
||||
|
||||
// First 100 ms sleep in chunks of 10 ms.
|
||||
for _ in 0..10 {
|
||||
if self.all_work_done().await {
|
||||
if self.work_done(include_smtp).await {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
|
||||
// If we are not finished in 100 ms, keep waking up every 100 ms.
|
||||
while !self.all_work_done().await {
|
||||
while !self.work_done(include_smtp).await {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
@@ -576,6 +584,34 @@ impl Context {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::TestContext;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_background_fetch_leaves_smtp_alone() -> Result<()> {
|
||||
let alice = TestContext::new_alice().await;
|
||||
alice.start_io().await;
|
||||
alice.wait_for_all_work_done().await;
|
||||
|
||||
let smtp = match *alice.scheduler.inner.read().await {
|
||||
InnerSchedulerState::Started(ref scheduler) => {
|
||||
scheduler.smtp.state.connectivity.clone()
|
||||
}
|
||||
_ => panic!("scheduler is not running"),
|
||||
};
|
||||
smtp.set_working(&alice);
|
||||
|
||||
alice.background_fetch().await?;
|
||||
assert!(!smtp.get_all_work_done());
|
||||
assert!(alice.scheduler.is_running().await);
|
||||
|
||||
alice
|
||||
.assert_warns_or_errors(&[
|
||||
"No IMAP connection candidates provided",
|
||||
"IMAP got rate limited",
|
||||
])
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_combine_connectivities() {
|
||||
|
||||
+85
-158
@@ -1,6 +1,7 @@
|
||||
//! # SMTP transport module.
|
||||
|
||||
mod connect;
|
||||
pub(crate) mod queue;
|
||||
pub mod send;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
@@ -8,27 +9,24 @@ use std::collections::BTreeSet;
|
||||
use anyhow::{Context as _, Error, Result, bail, format_err};
|
||||
use async_smtp::response::{Category, Code, Detail};
|
||||
use async_smtp::{EmailAddress, SmtpTransport};
|
||||
use pgp::composed::SignedPublicKey;
|
||||
use rusqlite::OptionalExtension as _;
|
||||
use tokio::task;
|
||||
|
||||
use crate::chat;
|
||||
use crate::chat::{ChatId, add_info_msg_with_cmd};
|
||||
use crate::config::Config;
|
||||
use crate::contact::{Contact, ContactId};
|
||||
use crate::context::Context;
|
||||
use crate::events::EventType;
|
||||
use crate::key;
|
||||
use crate::key::DcKey;
|
||||
use crate::log::{LogExt, warn};
|
||||
use crate::message::Message;
|
||||
use crate::message::{self, MsgId};
|
||||
use crate::mimefactory;
|
||||
use crate::mimefactory::MimeFactory;
|
||||
use crate::mimefactory::QueuedMail;
|
||||
use crate::net::proxy::ProxyConfig;
|
||||
use crate::net::session::SessionBufStream;
|
||||
use crate::scheduler::connectivity::ConnectivityStore;
|
||||
use crate::smtp::queue::QueuedMail;
|
||||
use crate::stock_str::unencrypted_email;
|
||||
use crate::tools::{self, time_elapsed};
|
||||
use crate::transport::{
|
||||
@@ -43,6 +41,9 @@ pub(crate) struct Smtp {
|
||||
/// Email address we are sending from.
|
||||
from: Option<EmailAddress>,
|
||||
|
||||
/// Transport we are connected to.
|
||||
transport_id: Option<u32>,
|
||||
|
||||
/// Timestamp of last successful send/receive network interaction
|
||||
/// (eg connect or send succeeded). On initialization and disconnect
|
||||
/// it is set to None.
|
||||
@@ -98,19 +99,39 @@ impl Smtp {
|
||||
}
|
||||
|
||||
self.connectivity.set_connecting(context);
|
||||
let (_transport_id, lp) = ConfiguredLoginParam::load(context)
|
||||
.await?
|
||||
.context("Not configured")?;
|
||||
let proxy_config = ProxyConfig::load(context).await?;
|
||||
self.connect(
|
||||
context,
|
||||
&lp.smtp,
|
||||
&lp.smtp_password,
|
||||
&proxy_config,
|
||||
&lp.addr,
|
||||
lp.strict_tls(proxy_config.is_some())?,
|
||||
)
|
||||
.await
|
||||
let transports = ConfiguredLoginParam::load_all(context).await?;
|
||||
|
||||
// Try to connect to the newest transport first. If sending is unreliable,
|
||||
// user can configure a new transport and it will be the one used.
|
||||
// Conversely, if user just added a new transport and sending got less reliable,
|
||||
// user can restore old state by removing the just added transport.
|
||||
for (transport_id, lp) in transports.into_iter().rev() {
|
||||
info!(context, "Trying to connect to transport {transport_id}.");
|
||||
match self
|
||||
.connect(
|
||||
context,
|
||||
&lp.smtp,
|
||||
&lp.smtp_password,
|
||||
&proxy_config,
|
||||
&lp.addr,
|
||||
lp.strict_tls(proxy_config.is_some())?,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
self.transport_id = Some(transport_id);
|
||||
return Ok(());
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
context,
|
||||
"Failed to connect to SMTP transport {transport_id}: {err:#}."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
bail!("Failed to connect to any SMTP server");
|
||||
}
|
||||
|
||||
/// Connect using the provided login params.
|
||||
@@ -197,15 +218,6 @@ pub(crate) async fn smtp_send(
|
||||
|
||||
smtp.connectivity.set_working(context);
|
||||
|
||||
if let Err(err) = smtp
|
||||
.connect_configured(context)
|
||||
.await
|
||||
.context("Failed to open SMTP connection")
|
||||
{
|
||||
smtp.last_send_error = Some(format!("{err:#}"));
|
||||
return SendResult::Retry;
|
||||
}
|
||||
|
||||
let send_result = smtp.send(context, recipients, message.as_bytes()).await;
|
||||
smtp.last_send_error = send_result.as_ref().err().map(|e| e.to_string());
|
||||
|
||||
@@ -347,7 +359,7 @@ pub(crate) async fn insert_into_smtp(
|
||||
let msg_id = message::insert_tombstone(context, rfc724_mid).await?;
|
||||
context
|
||||
.sql
|
||||
.transaction(|transaction| chat::enqueue_mail(transaction, now, msg_id, queued_msg, None))
|
||||
.transaction(|transaction| queue::enqueue_mail(transaction, now, msg_id, queued_msg, None))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -396,7 +408,7 @@ pub(crate) async fn send_msg_to_smtp(
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let queued_mail = load_queued_mail(transaction, rowid)
|
||||
let queued_mail = queue::load_queued_mail(transaction, rowid)
|
||||
.with_context(|| format!("Failed to load queued mail for {rowid}"))?;
|
||||
Ok(Some((queued_mail, msg_id, retries)))
|
||||
})
|
||||
@@ -419,10 +431,16 @@ pub(crate) async fn send_msg_to_smtp(
|
||||
|
||||
let mut recipients = queued_mail.recipients.clone();
|
||||
if queued_mail.bcc_self {
|
||||
let from_addr = smtp
|
||||
.from
|
||||
.as_ref()
|
||||
.context("No From address available, likely not connected")?
|
||||
.to_string();
|
||||
add_self_recipients(
|
||||
context,
|
||||
&mut recipients,
|
||||
queued_mail.encryption.is_encrypted(),
|
||||
from_addr,
|
||||
)
|
||||
.await
|
||||
.context("Failed to add self recipients")?;
|
||||
@@ -451,6 +469,12 @@ pub(crate) async fn send_msg_to_smtp(
|
||||
.context("No From address available, likely not connected")?
|
||||
.to_string();
|
||||
|
||||
let transport_id = smtp.transport_id.context("Not connected")?;
|
||||
let chunk_size = context
|
||||
.get_max_smtp_rcpt_to(transport_id, &from_addr)
|
||||
.await?
|
||||
.max(1);
|
||||
|
||||
let rendered_mail =
|
||||
mimefactory::render_queued_mail(queued_mail, &public_key, &secret_key, from_addr)?;
|
||||
let body = rendered_mail.message;
|
||||
@@ -459,24 +483,22 @@ pub(crate) async fn send_msg_to_smtp(
|
||||
context,
|
||||
"Try number {retries} to send message {msg_id} (entry {rowid}) over SMTP."
|
||||
);
|
||||
|
||||
let chunk_size = context.get_max_smtp_rcpt_to().await?.max(1);
|
||||
let mut unsent = recipients_list.as_slice();
|
||||
let status = loop {
|
||||
let unsent_len = u32::try_from(unsent.len()).context("Too many SMTP recipients")?;
|
||||
let split_index = usize::try_from(chunk_size.min(unsent_len))
|
||||
.context("Failed to convert SMTP chunk size")?;
|
||||
let (chunk, rest) = unsent.split_at(split_index);
|
||||
let status = smtp_send(context, chunk, body.as_str(), smtp, Some(msg_id)).await;
|
||||
if !matches!(status, SendResult::Success) || rest.is_empty() {
|
||||
break status;
|
||||
}
|
||||
for sent_to_addr in chunk {
|
||||
let sent_to_addr_string = sent_to_addr.to_string();
|
||||
if !sent_to_set.insert(sent_to_addr_string) {
|
||||
error!(context, "Attempted to send to {sent_to_addr} twice.");
|
||||
}
|
||||
}
|
||||
let status = smtp_send(context, chunk, body.as_str(), smtp, Some(msg_id)).await;
|
||||
if !matches!(status, SendResult::Success) || rest.is_empty() {
|
||||
break status;
|
||||
}
|
||||
let sent_to_str = sent_to_set
|
||||
.iter()
|
||||
.map(|a| a.as_ref())
|
||||
@@ -499,6 +521,15 @@ pub(crate) async fn send_msg_to_smtp(
|
||||
.sql
|
||||
.execute("DELETE FROM smtp2 WHERE id=?", (rowid,))
|
||||
.await?;
|
||||
|
||||
let sent_to_len = sent_to_set.len();
|
||||
debug_assert!(sent_to_len > 0);
|
||||
let info_msg = format!(
|
||||
"Message len={} was SMTP-sent to {sent_to_len} recipients.",
|
||||
body.len()
|
||||
);
|
||||
info!(context, "{info_msg}.");
|
||||
context.emit_event(EventType::SmtpMessageSent(info_msg));
|
||||
}
|
||||
SendResult::Failure(ref err) => {
|
||||
if err
|
||||
@@ -669,11 +700,16 @@ async fn send_mdn_rfc724_mid(
|
||||
} else {
|
||||
mimefactory.recipients()
|
||||
};
|
||||
let rendered_msg = Box::pin(mimefactory.render(context)).await?;
|
||||
let from = smtp
|
||||
.from
|
||||
.as_ref()
|
||||
.context("No From address, not connected")?
|
||||
.to_string();
|
||||
let rendered_msg = Box::pin(mimefactory.render(context, &from)).await?;
|
||||
let body = rendered_msg.message;
|
||||
|
||||
if context.get_config_bool(Config::BccSelf).await? {
|
||||
add_self_recipients(context, &mut recipients, encrypted).await?;
|
||||
add_self_recipients(context, &mut recipients, encrypted, from).await?;
|
||||
}
|
||||
let recipients: Vec<_> = recipients
|
||||
.into_iter()
|
||||
@@ -738,6 +774,19 @@ async fn send_mdn(context: &Context, smtp: &mut Smtp) -> Result<bool> {
|
||||
};
|
||||
let (rfc724_mid, contact_id) = msg_row;
|
||||
|
||||
// Connect SMTP after checking that we have an MDN to send,
|
||||
// but before increasing MDN retry counter.
|
||||
// If we don't have an MDN, no need to connect.
|
||||
// If we are offline and cannot connect, it is not a failure of an MDN.
|
||||
if let Err(err) = smtp
|
||||
.connect_configured(context)
|
||||
.await
|
||||
.context("SMTP connection failure while preparing to send MDNs")
|
||||
{
|
||||
smtp.last_send_error = Some(format!("{err:#}"));
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
@@ -777,11 +826,11 @@ pub(crate) async fn add_self_recipients(
|
||||
context: &Context,
|
||||
recipients: &mut Vec<String>,
|
||||
encrypted: bool,
|
||||
from: String,
|
||||
) -> Result<()> {
|
||||
// Avoid sending unencrypted messages to all transports,
|
||||
// chatmail relays won't accept them. Normally the user should have
|
||||
// a non-chatmail sending transport to send unencrypted messages.
|
||||
let from = context.get_primary_self_addr().await?;
|
||||
if encrypted {
|
||||
for addr in context.get_self_addrs().await? {
|
||||
if addr != from {
|
||||
@@ -799,125 +848,3 @@ pub(crate) async fn add_self_recipients(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns true if SMTP queue is empty.
|
||||
pub(crate) async fn is_queue_empty(context: &Context) -> Result<bool> {
|
||||
let sending_finished = !context.sql.exists("SELECT COUNT(*) FROM smtp2", ()).await?;
|
||||
Ok(sending_finished)
|
||||
}
|
||||
|
||||
/// Loads the queued mail from `smtp2` table and the list of recipients.
|
||||
pub(crate) fn load_queued_mail(
|
||||
transaction: &mut rusqlite::Transaction<'_>,
|
||||
row_id: i64,
|
||||
) -> Result<QueuedMail> {
|
||||
let (mut queued_mail, encryption_fingerprints) = transaction
|
||||
.query_row_and_then(
|
||||
"
|
||||
SELECT display_name,
|
||||
rfc724_mid,
|
||||
mime,
|
||||
should_attach_pubkey,
|
||||
should_compress,
|
||||
should_sign,
|
||||
is_encrypted,
|
||||
shared_secret,
|
||||
encryption_fingerprints,
|
||||
recipients,
|
||||
sent_to,
|
||||
bcc_self
|
||||
FROM smtp2 WHERE id = ?
|
||||
",
|
||||
(row_id,),
|
||||
|row| {
|
||||
let display_name: String = row.get(0)?;
|
||||
let rfc724_mid: String = row.get(1)?;
|
||||
let raw_message: Vec<u8> = row.get(2)?;
|
||||
let should_attach_pubkey: bool = row.get(3)?;
|
||||
let should_compress: bool = row.get(4)?;
|
||||
let should_sign: bool = row.get(5)?;
|
||||
let is_encrypted: bool = row.get(6)?;
|
||||
let shared_secret: String = row.get(7)?;
|
||||
let encryption_fingerprints: String = row.get(8)?;
|
||||
let encryption_fingerprints: Vec<String> = if encryption_fingerprints.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
encryption_fingerprints
|
||||
.split(' ')
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
};
|
||||
let recipients: String = row.get(9)?;
|
||||
let recipients: Vec<String> = if recipients.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
recipients.split(' ').map(|s| s.to_string()).collect()
|
||||
};
|
||||
debug_assert!(!recipients.iter().any(|s| s.is_empty()));
|
||||
let sent_to: String = row.get(10)?;
|
||||
let sent_to: Vec<String> = if sent_to.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
sent_to.split(' ').map(|s| s.to_string()).collect()
|
||||
};
|
||||
let bcc_self: bool = row.get(11)?;
|
||||
|
||||
let encryption = match (
|
||||
is_encrypted,
|
||||
shared_secret.is_empty(),
|
||||
encryption_fingerprints.is_empty(),
|
||||
) {
|
||||
(false, true, true) => mimefactory::QueuedEncryption::No,
|
||||
(true, false, true) => {
|
||||
mimefactory::QueuedEncryption::Symmetric { shared_secret }
|
||||
}
|
||||
(true, true, _) => mimefactory::QueuedEncryption::Asymmetric {
|
||||
// Public keys are loaded below based on the encryption fingerprints.
|
||||
encryption_pubkeys: Vec::new(),
|
||||
},
|
||||
_ => bail!("Invalid encryption in smtp2 row"),
|
||||
};
|
||||
Ok::<_, anyhow::Error>((
|
||||
QueuedMail {
|
||||
raw_message,
|
||||
display_name,
|
||||
rfc724_mid,
|
||||
encryption,
|
||||
should_attach_pubkey,
|
||||
should_compress,
|
||||
should_sign,
|
||||
recipients,
|
||||
sent_to,
|
||||
bcc_self,
|
||||
},
|
||||
encryption_fingerprints,
|
||||
))
|
||||
},
|
||||
)
|
||||
.with_context(|| format!("Failed to select row {row_id} from smtp2 table"))?;
|
||||
|
||||
if let mimefactory::QueuedEncryption::Asymmetric {
|
||||
ref mut encryption_pubkeys,
|
||||
} = queued_mail.encryption
|
||||
{
|
||||
for fingerprint in encryption_fingerprints {
|
||||
let public_key_bytes: Option<Vec<u8>> = transaction
|
||||
.query_row(
|
||||
"SELECT public_key FROM public_keys WHERE fingerprint=?",
|
||||
(fingerprint,),
|
||||
|row| {
|
||||
let bytes: Vec<u8> = row.get(0)?;
|
||||
Ok(bytes)
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.context("Failed to select public key by fingerprint")?;
|
||||
if let Some(public_key_bytes) = public_key_bytes {
|
||||
let public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
|
||||
encryption_pubkeys.push(public_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(queued_mail)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
//! # SMTP queue module.
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
|
||||
use crate::chat::ChatId;
|
||||
use crate::context::Context;
|
||||
use crate::key::{DcKey, SignedPublicKey};
|
||||
use crate::message::MsgId;
|
||||
use rusqlite::OptionalExtension as _;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Encryption {
|
||||
/// Unencrypted message.
|
||||
No,
|
||||
|
||||
/// The message is encrypted asymmetrically to public keys.
|
||||
Asymmetric {
|
||||
/// OpenPGP keys to use for encryption.
|
||||
///
|
||||
/// The message is always encrypted to self,
|
||||
/// no need to include own key here.
|
||||
encryption_pubkeys: Vec<SignedPublicKey>,
|
||||
},
|
||||
|
||||
/// Symmetrically encrypted message with a shared secret.
|
||||
Symmetric { shared_secret: String },
|
||||
}
|
||||
|
||||
impl Encryption {
|
||||
pub(crate) fn is_encrypted(&self) -> bool {
|
||||
match self {
|
||||
Self::No => false,
|
||||
Self::Asymmetric { .. } => true,
|
||||
Self::Symmetric { .. } => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Email message queued, but not sent yet.
|
||||
///
|
||||
/// It is stored unencrypted to
|
||||
/// make it possible to change protected headers
|
||||
/// like the From address and Autocrypt header later.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct QueuedMail {
|
||||
/// Unencrypted queued message.
|
||||
///
|
||||
/// This message has both the headers and the body,
|
||||
/// but without the From, Autocrypt and Message-ID headers.
|
||||
///
|
||||
/// For encrypted messages this is the OpenPGP payload.
|
||||
pub(crate) raw_message: Vec<u8>,
|
||||
|
||||
/// Display name to put in the `From:` field.
|
||||
///
|
||||
/// Email address is not determined yet here.
|
||||
pub(crate) display_name: String,
|
||||
|
||||
/// Message-ID.
|
||||
pub(crate) rfc724_mid: String,
|
||||
|
||||
/// Whether the message is encrypted and encryption keys.
|
||||
pub(crate) encryption: Encryption,
|
||||
|
||||
/// If true, Autocrypt header should be added before sending.
|
||||
pub(crate) should_attach_pubkey: bool,
|
||||
|
||||
/// If true, OpenPGP compression may be used.
|
||||
pub(crate) should_compress: bool,
|
||||
|
||||
/// If true, encrypted message should be signed.
|
||||
pub(crate) should_sign: bool,
|
||||
|
||||
/// Recipient addresses.
|
||||
pub(crate) recipients: Vec<String>,
|
||||
|
||||
/// Addresses the messages was already sent to.
|
||||
pub(crate) sent_to: Vec<String>,
|
||||
|
||||
/// If true, own addresses should be added to the list of recipients.
|
||||
///
|
||||
/// For unencrypted messages, only the sending addresses should be added.
|
||||
/// For encrypted messages, all published addresses should be added.
|
||||
pub(crate) bcc_self: bool,
|
||||
}
|
||||
|
||||
/// Side effects that should be applied at the same time
|
||||
/// as the message is persisted in the queue.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SideEffects {
|
||||
/// ID of the chat side effects should be applied to.
|
||||
pub chat_id: ChatId,
|
||||
|
||||
/// Largest timestamp of the location sent in `location.kml` in this message.
|
||||
pub last_added_location_timestamp: Option<i64>,
|
||||
|
||||
/// True if the message has the avatar attached.
|
||||
///
|
||||
/// Timestamp of the last time avatar was gossiped should be updated.
|
||||
pub avatar_is_attached: bool,
|
||||
|
||||
/// A comma-separated string of sync-IDs that are used by the rendered email and must be deleted
|
||||
/// from `multi_device_sync` once the message is actually queued for sending.
|
||||
pub sync_ids_to_delete: Option<String>,
|
||||
|
||||
/// Subject that was rendered into the message.
|
||||
///
|
||||
/// Used to update the subject on the sent message object.
|
||||
pub subject: String,
|
||||
}
|
||||
|
||||
/// Email message ready to be queued with the side effects that should be applied at the same time.
|
||||
pub(crate) type ToBeQueuedMail = (QueuedMail, Option<SideEffects>);
|
||||
|
||||
/// Process side effects and store queued mail.
|
||||
pub(crate) fn enqueue_mail(
|
||||
transaction: &mut rusqlite::Transaction<'_>,
|
||||
now: i64,
|
||||
msg_id: MsgId,
|
||||
queued_mail: &QueuedMail,
|
||||
side_effects: Option<&SideEffects>,
|
||||
) -> Result<i64> {
|
||||
if let Some(side_effects) = side_effects {
|
||||
if let Some(last_added_location_timestamp) = side_effects.last_added_location_timestamp {
|
||||
transaction.execute(
|
||||
"UPDATE chats SET locations_last_sent=? WHERE id=?;",
|
||||
(last_added_location_timestamp, side_effects.chat_id),
|
||||
)?;
|
||||
}
|
||||
|
||||
if side_effects.avatar_is_attached {
|
||||
side_effects
|
||||
.chat_id
|
||||
.set_selfavatar_timestamp(transaction, now)
|
||||
.context("Failed to set selfavatar timestamp")?;
|
||||
}
|
||||
|
||||
if let Some(ref sync_ids) = side_effects.sync_ids_to_delete {
|
||||
transaction.execute(
|
||||
&format!("DELETE FROM multi_device_sync WHERE id IN ({sync_ids})"),
|
||||
(),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Store mail into queue.
|
||||
let all_recipients = queued_mail.recipients.join(" ");
|
||||
let is_encrypted = queued_mail.encryption.is_encrypted();
|
||||
|
||||
transaction
|
||||
.execute(
|
||||
"
|
||||
INSERT INTO smtp2 (
|
||||
display_name,
|
||||
rfc724_mid,
|
||||
mime,
|
||||
should_attach_pubkey,
|
||||
should_compress,
|
||||
should_sign,
|
||||
msg_id,
|
||||
recipients,
|
||||
bcc_self,
|
||||
is_encrypted,
|
||||
shared_secret,
|
||||
encryption_fingerprints
|
||||
)
|
||||
VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
",
|
||||
(
|
||||
&queued_mail.display_name,
|
||||
&queued_mail.rfc724_mid,
|
||||
&queued_mail.raw_message,
|
||||
queued_mail.should_attach_pubkey,
|
||||
queued_mail.should_compress,
|
||||
queued_mail.should_sign,
|
||||
msg_id,
|
||||
&all_recipients,
|
||||
queued_mail.bcc_self,
|
||||
is_encrypted,
|
||||
if let Encryption::Symmetric { ref shared_secret } = queued_mail.encryption {
|
||||
shared_secret
|
||||
} else {
|
||||
""
|
||||
},
|
||||
if let Encryption::Asymmetric {
|
||||
ref encryption_pubkeys,
|
||||
} = queued_mail.encryption
|
||||
{
|
||||
let res: Vec<String> = encryption_pubkeys
|
||||
.iter()
|
||||
.map(|pubkey| pubkey.dc_fingerprint().hex())
|
||||
.collect();
|
||||
res.join(" ")
|
||||
} else {
|
||||
"".to_string()
|
||||
},
|
||||
),
|
||||
)
|
||||
.context("Failed to insert a row into smtp2 table")?;
|
||||
let row_id = transaction.last_insert_rowid();
|
||||
Ok(row_id)
|
||||
}
|
||||
|
||||
/// Loads the queued mail from `smtp2` table and the list of recipients.
|
||||
pub(crate) fn load_queued_mail(
|
||||
transaction: &mut rusqlite::Transaction<'_>,
|
||||
row_id: i64,
|
||||
) -> Result<QueuedMail> {
|
||||
let (mut queued_mail, encryption_fingerprints) = transaction
|
||||
.query_row_and_then(
|
||||
"
|
||||
SELECT display_name,
|
||||
rfc724_mid,
|
||||
mime,
|
||||
should_attach_pubkey,
|
||||
should_compress,
|
||||
should_sign,
|
||||
is_encrypted,
|
||||
shared_secret,
|
||||
encryption_fingerprints,
|
||||
recipients,
|
||||
sent_to,
|
||||
bcc_self
|
||||
FROM smtp2 WHERE id = ?
|
||||
",
|
||||
(row_id,),
|
||||
|row| {
|
||||
let display_name: String = row.get(0)?;
|
||||
let rfc724_mid: String = row.get(1)?;
|
||||
let raw_message: Vec<u8> = row.get(2)?;
|
||||
let should_attach_pubkey: bool = row.get(3)?;
|
||||
let should_compress: bool = row.get(4)?;
|
||||
let should_sign: bool = row.get(5)?;
|
||||
let is_encrypted: bool = row.get(6)?;
|
||||
let shared_secret: String = row.get(7)?;
|
||||
let encryption_fingerprints: String = row.get(8)?;
|
||||
let encryption_fingerprints: Vec<String> = if encryption_fingerprints.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
encryption_fingerprints
|
||||
.split(' ')
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
};
|
||||
let recipients: String = row.get(9)?;
|
||||
let recipients: Vec<String> = if recipients.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
recipients.split(' ').map(|s| s.to_string()).collect()
|
||||
};
|
||||
debug_assert!(!recipients.iter().any(|s| s.is_empty()));
|
||||
let sent_to: String = row.get(10)?;
|
||||
let sent_to: Vec<String> = if sent_to.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
sent_to.split(' ').map(|s| s.to_string()).collect()
|
||||
};
|
||||
let bcc_self: bool = row.get(11)?;
|
||||
|
||||
let encryption = match (
|
||||
is_encrypted,
|
||||
shared_secret.is_empty(),
|
||||
encryption_fingerprints.is_empty(),
|
||||
) {
|
||||
(false, true, true) => Encryption::No,
|
||||
(true, false, true) => Encryption::Symmetric { shared_secret },
|
||||
(true, true, _) => Encryption::Asymmetric {
|
||||
// Public keys are loaded below based on the encryption fingerprints.
|
||||
encryption_pubkeys: Vec::new(),
|
||||
},
|
||||
_ => bail!("Invalid encryption in smtp2 row"),
|
||||
};
|
||||
Ok::<_, anyhow::Error>((
|
||||
QueuedMail {
|
||||
raw_message,
|
||||
display_name,
|
||||
rfc724_mid,
|
||||
encryption,
|
||||
should_attach_pubkey,
|
||||
should_compress,
|
||||
should_sign,
|
||||
recipients,
|
||||
sent_to,
|
||||
bcc_self,
|
||||
},
|
||||
encryption_fingerprints,
|
||||
))
|
||||
},
|
||||
)
|
||||
.with_context(|| format!("Failed to select row {row_id} from smtp2 table"))?;
|
||||
|
||||
if let Encryption::Asymmetric {
|
||||
ref mut encryption_pubkeys,
|
||||
} = queued_mail.encryption
|
||||
{
|
||||
for fingerprint in encryption_fingerprints {
|
||||
let public_key_bytes: Option<Vec<u8>> = transaction
|
||||
.query_row(
|
||||
"SELECT public_key FROM public_keys WHERE fingerprint=?",
|
||||
(fingerprint,),
|
||||
|row| {
|
||||
let bytes: Vec<u8> = row.get(0)?;
|
||||
Ok(bytes)
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.context("Failed to select public key by fingerprint")?;
|
||||
if let Some(public_key_bytes) = public_key_bytes {
|
||||
let public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
|
||||
encryption_pubkeys.push(public_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(queued_mail)
|
||||
}
|
||||
|
||||
/// Returns true if SMTP queue is empty.
|
||||
pub(crate) async fn is_empty(context: &Context) -> Result<bool> {
|
||||
let sending_finished = !context.sql.exists("SELECT COUNT(*) FROM smtp2", ()).await?;
|
||||
Ok(sending_finished)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ use async_smtp::{EmailAddress, Envelope, SendableEmail};
|
||||
use super::Smtp;
|
||||
use crate::config::Config;
|
||||
use crate::context::Context;
|
||||
use crate::events::EventType;
|
||||
use crate::log::warn;
|
||||
use crate::tools;
|
||||
|
||||
@@ -39,8 +38,6 @@ impl Smtp {
|
||||
context.ratelimit.write().await.send();
|
||||
}
|
||||
|
||||
let message_len_bytes = message.len();
|
||||
|
||||
let envelope =
|
||||
Envelope::new(self.from.clone(), recipients.to_vec()).map_err(Error::Envelope)?;
|
||||
let mail = SendableEmail::new(envelope, message);
|
||||
@@ -55,12 +52,6 @@ impl Smtp {
|
||||
|
||||
transport.send(mail).await.map_err(Error::SmtpSend)?;
|
||||
|
||||
let info_msg = format!(
|
||||
"Message len={message_len_bytes} was SMTP-sent to {} recipients.",
|
||||
recipients.len()
|
||||
);
|
||||
info!(context, "{info_msg}.");
|
||||
context.emit_event(EventType::SmtpMessageSent(info_msg));
|
||||
self.last_success = Some(tools::Time::now());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2673,6 +2673,13 @@ CREATE TABLE smtp2 (
|
||||
.await?;
|
||||
}
|
||||
|
||||
inc_and_check(&mut migration_version, 167)?;
|
||||
if dbversion < migration_version {
|
||||
// The default relay candidates are no longer seeded into the table.
|
||||
sql.execute_migration("DELETE FROM relay_candidates", migration_version)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let new_version = sql
|
||||
.get_raw_config_int(VERSION_CFG)
|
||||
.await?
|
||||
|
||||
@@ -16,15 +16,15 @@ async fn test_clear_config_cache() -> anyhow::Result<()> {
|
||||
// This test checks that the config cache is invalidated in `execute_migration()`.
|
||||
|
||||
let t = TestContext::new().await;
|
||||
assert_eq!(t.get_config_bool(Config::IsChatmail).await?, false);
|
||||
assert_eq!(t.sql.get_raw_config_bool("cached_key").await?, false);
|
||||
|
||||
t.sql
|
||||
.execute_migration(
|
||||
"INSERT INTO config (keyname, value) VALUES ('is_chatmail', '1')",
|
||||
"INSERT INTO config (keyname, value) VALUES ('cached_key', '1')",
|
||||
1000,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(t.get_config_bool(Config::IsChatmail).await?, true);
|
||||
assert_eq!(t.sql.get_raw_config_bool("cached_key").await?, true);
|
||||
assert_eq!(t.sql.get_raw_config_int(VERSION_CFG).await?.unwrap(), 1000);
|
||||
|
||||
Ok(())
|
||||
|
||||
+10
-3
@@ -42,7 +42,8 @@ struct Statistics {
|
||||
/// Size of the public key in bytes (encoded in binary, not base64).
|
||||
pubkey_size: usize,
|
||||
stats_id: String,
|
||||
is_chatmail: bool,
|
||||
/// Whether all transports are chatmail relays, `None` if not known for all of them.
|
||||
is_chatmail: Option<bool>,
|
||||
contact_stats: Vec<ContactStat>,
|
||||
message_stats: BTreeMap<Chattype, MessageStats>,
|
||||
securejoin_sources: SecurejoinSources,
|
||||
@@ -350,16 +351,22 @@ async fn get_stats(context: &Context) -> Result<String> {
|
||||
let sending_disabled_timestamps =
|
||||
get_timestamps(context, "stats_sending_disabled_events").await?;
|
||||
|
||||
let number_of_transports = context.count_transports().await?;
|
||||
let metadata = context.metadata.read().await;
|
||||
let is_chatmail = (!metadata.is_empty() && metadata.len() == number_of_transports)
|
||||
.then(|| metadata.values().all(|m| m.supports_push));
|
||||
drop(metadata);
|
||||
|
||||
let stats = Statistics {
|
||||
core_version: DC_VERSION_STR.to_string(),
|
||||
number_of_transports: context.count_transports().await?,
|
||||
number_of_transports,
|
||||
key_create_timestamps,
|
||||
number_of_keys,
|
||||
key_version: self_public_key.primary_key.version().into(),
|
||||
key_algorithm: format!("{:?}", self_public_key.algorithm()),
|
||||
pubkey_size: DcKey::to_bytes(&self_public_key).len(),
|
||||
stats_id: stats_id(context).await?,
|
||||
is_chatmail: context.is_chatmail().await?,
|
||||
is_chatmail,
|
||||
contact_stats: get_contact_stats(context, last_old_contact).await?,
|
||||
message_stats: get_message_stats(context).await?,
|
||||
securejoin_sources: get_securejoin_source_stats(context).await?,
|
||||
|
||||
@@ -4,11 +4,13 @@ use super::*;
|
||||
use crate::chat::{
|
||||
Chat, create_broadcast, create_group, create_group_unencrypted, get_chat_contacts,
|
||||
};
|
||||
use crate::imap::ServerMetadata;
|
||||
use crate::mimeparser::SystemMessage;
|
||||
use crate::qr::check_qr;
|
||||
use crate::securejoin::{get_securejoin_qr, join_securejoin, join_securejoin_with_ux_info};
|
||||
use crate::test_utils::{TestContext, TestContextManager, get_chat_msg};
|
||||
use crate::tools::SystemTime;
|
||||
use crate::transport::add_pseudo_transport;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::{Number, Value};
|
||||
|
||||
@@ -463,18 +465,33 @@ async fn test_stats_securejoin_invites() -> Result<()> {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_stats_is_chatmail() -> Result<()> {
|
||||
async fn is_chatmail(context: &TestContext) -> Result<Value> {
|
||||
let stats: Value = serde_json::from_str(&get_stats(context).await?)?;
|
||||
Ok(stats.get("is_chatmail").unwrap().clone())
|
||||
}
|
||||
|
||||
let alice = &TestContext::new_alice().await;
|
||||
alice.set_config_bool(Config::StatsSending, true).await?;
|
||||
assert!(is_chatmail(alice).await?.is_null());
|
||||
|
||||
let r = get_stats(alice).await?;
|
||||
let r: serde_json::Value = serde_json::from_str(&r)?;
|
||||
assert_eq!(r.get("is_chatmail").unwrap().as_bool().unwrap(), false);
|
||||
alice.metadata.write().await.insert(
|
||||
0,
|
||||
ServerMetadata {
|
||||
supports_push: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(is_chatmail(alice).await?, Value::Bool(true));
|
||||
|
||||
alice.set_config_bool(Config::IsChatmail, true).await?;
|
||||
add_pseudo_transport(alice, "alice@example.net").await?;
|
||||
assert!(is_chatmail(alice).await?.is_null());
|
||||
|
||||
let r = get_stats(alice).await?;
|
||||
let r: serde_json::Value = serde_json::from_str(&r)?;
|
||||
assert_eq!(r.get("is_chatmail").unwrap().as_bool().unwrap(), true);
|
||||
alice
|
||||
.metadata
|
||||
.write()
|
||||
.await
|
||||
.insert(1, ServerMetadata::default());
|
||||
assert_eq!(is_chatmail(alice).await?, Value::Bool(false));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+2
-7
@@ -703,9 +703,7 @@ mod tests {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_send_sync_msg_enables_bccself() -> Result<()> {
|
||||
for (chatmail, sync_message_sent) in
|
||||
[(false, false), (false, true), (true, false), (true, true)]
|
||||
{
|
||||
for sync_message_sent in [false, true] {
|
||||
let alice1 = TestContext::new_alice().await;
|
||||
let alice2 = TestContext::new_alice().await;
|
||||
|
||||
@@ -714,9 +712,6 @@ mod tests {
|
||||
alice1.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
alice2.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
|
||||
alice1.set_config_bool(Config::IsChatmail, chatmail).await?;
|
||||
alice2.set_config_bool(Config::IsChatmail, chatmail).await?;
|
||||
|
||||
alice1.set_config_bool(Config::BccSelf, true).await?;
|
||||
alice2.set_config_bool(Config::BccSelf, false).await?;
|
||||
|
||||
@@ -735,7 +730,7 @@ mod tests {
|
||||
alice1.send_text(chat.id, "Hi").await
|
||||
};
|
||||
|
||||
// On chatmail accounts, BccSelf defaults to false.
|
||||
// BccSelf defaults to false.
|
||||
// When receiving a sync message from another device,
|
||||
// there obviously is a multi-device-setup, and BccSelf
|
||||
// should be enabled.
|
||||
|
||||
+27
-3
@@ -35,7 +35,7 @@ use crate::context::Context;
|
||||
use crate::events::{Event, EventEmitter, EventType, Events};
|
||||
use crate::key::{self, DcKey, self_fingerprint};
|
||||
use crate::message::{Message, MessageState, MsgId};
|
||||
use crate::mimefactory;
|
||||
use crate::mimefactory::{self, MimeFactory};
|
||||
use crate::mimeparser::{MimeMessage, SystemMessage};
|
||||
use crate::pgp::SeipdVersion;
|
||||
use crate::receive_imf::{ReceivedMsg, receive_imf};
|
||||
@@ -557,6 +557,9 @@ impl TestContext {
|
||||
/// The context will be configured but the key will not be pre-generated so if a key is
|
||||
/// used the fingerprint will be different every time.
|
||||
pub async fn configure_addr(&self, addr: &str) {
|
||||
add_pseudo_transport(&self.ctx, addr)
|
||||
.await
|
||||
.expect("Failed to add pseudo transport");
|
||||
self.ctx
|
||||
.set_config(Config::ConfiguredAddr, Some(addr))
|
||||
.await
|
||||
@@ -624,15 +627,20 @@ ORDER BY id"
|
||||
.ctx
|
||||
.sql
|
||||
.transaction_ext(query_only, |transaction| {
|
||||
smtp::load_queued_mail(transaction, rowid)
|
||||
smtp::queue::load_queued_mail(transaction, rowid)
|
||||
})
|
||||
.await
|
||||
.expect("Failed to load queued mail");
|
||||
if queued_mail.bcc_self {
|
||||
let from = self
|
||||
.get_primary_self_addr()
|
||||
.await
|
||||
.expect("Cannot get From address");
|
||||
smtp::add_self_recipients(
|
||||
&self.ctx,
|
||||
&mut queued_mail.recipients,
|
||||
queued_mail.encryption.is_encrypted(),
|
||||
from,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to add self recipients");
|
||||
@@ -721,15 +729,20 @@ ORDER BY id"
|
||||
.ctx
|
||||
.sql
|
||||
.transaction_ext(query_only, |transaction| {
|
||||
smtp::load_queued_mail(transaction, rowid)
|
||||
smtp::queue::load_queued_mail(transaction, rowid)
|
||||
})
|
||||
.await
|
||||
.expect("Failed to load queued mail");
|
||||
if queued_mail.bcc_self {
|
||||
let from = self
|
||||
.get_primary_self_addr()
|
||||
.await
|
||||
.expect("Cannot get self address");
|
||||
smtp::add_self_recipients(
|
||||
&self.ctx,
|
||||
&mut queued_mail.recipients,
|
||||
queued_mail.encryption.is_encrypted(),
|
||||
from,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to add self recipients");
|
||||
@@ -837,6 +850,17 @@ ORDER BY id"
|
||||
assert_eq!(received.chat_id, ChatId::TRASH);
|
||||
}
|
||||
|
||||
/// Receives a read receipt from `reader`, who received `msg`.
|
||||
pub async fn recv_mdn(&self, reader: &TestContext, msg: &Message) -> Result<()> {
|
||||
let mdn = MimeFactory::from_mdn(reader, msg.from_id, msg.rfc724_mid.clone(), vec![])
|
||||
.await?
|
||||
.render(reader, &reader.get_primary_self_addr().await?)
|
||||
.await?
|
||||
.message;
|
||||
receive_imf(self, mdn.as_bytes(), false).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gets the most recent message ID of a chat.
|
||||
///
|
||||
/// Panics on errors or if the most recent message is a marker.
|
||||
|
||||
+2
-30
@@ -11,7 +11,7 @@
|
||||
use std::fmt;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use anyhow::{Context as _, Result, bail, format_err};
|
||||
use anyhow::{Context as _, Result, format_err};
|
||||
use deltachat_contact_tools::{EmailAddress, addr_normalize};
|
||||
use rusqlite::OptionalExtension;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -260,34 +260,6 @@ impl fmt::Display for ConfiguredLoginParam {
|
||||
}
|
||||
|
||||
impl ConfiguredLoginParam {
|
||||
/// Load configured account settings from the database.
|
||||
///
|
||||
/// Returns transport ID and configured parameters
|
||||
/// of the transport currently used for sending.
|
||||
/// Returns `None` if account is not configured.
|
||||
pub(crate) async fn load(context: &Context) -> Result<Option<(u32, Self)>> {
|
||||
let Some(self_addr) = context.get_config(Config::ConfiguredAddr).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some((id, json)) = context
|
||||
.sql
|
||||
.query_row_optional(
|
||||
"SELECT id, configured_param FROM transports WHERE addr=?",
|
||||
(&self_addr,),
|
||||
|row| {
|
||||
let id: u32 = row.get(0)?;
|
||||
let json: String = row.get(1)?;
|
||||
Ok((id, json))
|
||||
},
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
bail!("Self address {self_addr} doesn't have a corresponding transport");
|
||||
};
|
||||
Ok(Some((id, Self::from_json(&json)?)))
|
||||
}
|
||||
|
||||
/// Loads configured login parameters for all transports.
|
||||
///
|
||||
/// Returns a vector of all transport IDs
|
||||
@@ -765,7 +737,7 @@ pub(crate) fn maybe_update_sending_transport(
|
||||
}
|
||||
|
||||
/// Adds transport entry to the `transports` table with empty configuration.
|
||||
pub(crate) async fn add_pseudo_transport(context: &Context, addr: &str) -> Result<()> {
|
||||
pub async fn add_pseudo_transport(context: &Context, addr: &str) -> Result<()> {
|
||||
context.sql
|
||||
.execute(
|
||||
"INSERT OR IGNORE INTO transports (addr, entered_param, configured_param) VALUES (?, ?, ?)",
|
||||
|
||||
@@ -62,7 +62,7 @@ async fn test_save_load_login_param() -> Result<()> {
|
||||
expected_param
|
||||
);
|
||||
assert_eq!(t.is_configured().await?, true);
|
||||
let (_transport_id, loaded) = ConfiguredLoginParam::load(&t).await?.unwrap();
|
||||
let (_transport_id, loaded) = ConfiguredLoginParam::load_all(&t).await?.remove(0);
|
||||
assert_eq!(param, loaded);
|
||||
|
||||
let formatted = format!(" {loaded}");
|
||||
@@ -75,7 +75,7 @@ async fn test_save_load_login_param() -> Result<()> {
|
||||
// Legacy ConfiguredImapCertificateChecks config is ignored
|
||||
t.set_config(Config::ConfiguredImapCertificateChecks, Some("999"))
|
||||
.await?;
|
||||
assert!(ConfiguredLoginParam::load(&t).await.is_ok());
|
||||
assert!(ConfiguredLoginParam::load_all(&t).await.is_ok());
|
||||
|
||||
// Test that we don't panic on unknown ConfiguredImapCertificateChecks values.
|
||||
let wrong_param = expected_param.replace("Strict", "Stricct");
|
||||
@@ -83,7 +83,7 @@ async fn test_save_load_login_param() -> Result<()> {
|
||||
t.sql
|
||||
.execute("UPDATE transports SET configured_param=?", (wrong_param,))
|
||||
.await?;
|
||||
assert!(ConfiguredLoginParam::load(&t).await.is_err());
|
||||
assert!(ConfiguredLoginParam::load_all(&t).await.is_err());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -226,10 +226,11 @@ async fn test_delete_transport() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that promoting a transport bumps its `add_timestamp` on other devices
|
||||
/// even if it was added within the same second.
|
||||
/// Tests that selecting sending transport by setting "configured_addr" does not
|
||||
/// send the sync message, is not synchronized between devices even if sync message is forced,
|
||||
/// and does not bump sending transport `add_timestamp`.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_promote_transport_same_second() -> Result<()> {
|
||||
async fn test_no_configured_addr_synchronization() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let alice2 = &tcm.alice().await;
|
||||
@@ -238,11 +239,35 @@ async fn test_promote_transport_same_second() -> Result<()> {
|
||||
a.set_config_bool(Config::BccSelf, true).await?;
|
||||
}
|
||||
|
||||
add_dummy_transport(alice, "alice@otherprovider.com").await?;
|
||||
let addr = "alice@otherprovider.com";
|
||||
add_dummy_transport(alice, addr).await?;
|
||||
send_sync_transports(alice).await?;
|
||||
sync_and_check_recipients(alice, alice2, "alice@otherprovider.com alice@example.org").await;
|
||||
sync_and_check_recipients(alice, alice2, &format!("{addr} alice@example.org")).await;
|
||||
|
||||
promote_transport_and_sync(alice, alice2, "alice@otherprovider.com").await
|
||||
// Selects `addr` on `alice` as the sending transport
|
||||
// and syncs the transport update to `alice2`,
|
||||
// whose own sending transport must stay unchanged.
|
||||
let old_timestamp = add_timestamp(alice2, addr).await;
|
||||
let alice2_primary = alice2.get_config(Config::ConfiguredAddr).await?;
|
||||
alice.set_config(Config::ConfiguredAddr, Some(addr)).await?;
|
||||
assert_eq!(add_timestamp(alice, addr).await, old_timestamp);
|
||||
|
||||
send_sync_transports(alice).await?;
|
||||
alice.send_sync_msg().await?.unwrap();
|
||||
let sync_msg = alice.pop_sent_msg().await;
|
||||
assert_eq!(sync_msg.recipients, format!("alice@example.org {addr}"));
|
||||
// The sync message comes from the new sending address,
|
||||
// which must not make `alice2` adopt it as its own sending address.
|
||||
assert!(sync_msg.payload.contains(&format!("From: <{addr}>")));
|
||||
alice2.recv_msg_trash(&sync_msg).await;
|
||||
|
||||
// add_timestamp must not change.
|
||||
assert_eq!(add_timestamp(alice2, addr).await, old_timestamp);
|
||||
assert_eq!(
|
||||
alice2.get_config(Config::ConfiguredAddr).await?,
|
||||
alice2_primary
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that `sync_transports()` requests an IO restart
|
||||
@@ -263,36 +288,6 @@ async fn test_sync_transports_requests_io_restart() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Promotes `addr` on `alice` and syncs the transport update to `alice2`,
|
||||
/// whose own primary transport must stay unchanged.
|
||||
async fn promote_transport_and_sync(
|
||||
alice: &TestContext,
|
||||
alice2: &TestContext,
|
||||
addr: &str,
|
||||
) -> Result<()> {
|
||||
let old_timestamp = add_timestamp(alice2, addr).await;
|
||||
let alice2_primary = alice2.get_config(Config::ConfiguredAddr).await?;
|
||||
alice.set_config(Config::ConfiguredAddr, Some(addr)).await?;
|
||||
assert!(add_timestamp(alice, addr).await > old_timestamp);
|
||||
|
||||
alice.send_sync_msg().await?.unwrap();
|
||||
let sync_msg = alice.pop_sent_msg().await;
|
||||
assert_eq!(sync_msg.recipients, format!("alice@example.org {addr}"));
|
||||
// The sync message comes from the new primary,
|
||||
// which must not make `alice2` adopt it as its own primary.
|
||||
assert!(sync_msg.payload.contains(&format!("From: <{addr}>")));
|
||||
alice2.recv_msg_trash(&sync_msg).await;
|
||||
|
||||
// add_timestamp must monotonically increase because
|
||||
// other devices ignore the change otherwise.
|
||||
assert!(add_timestamp(alice2, addr).await > old_timestamp);
|
||||
assert_eq!(
|
||||
alice2.get_config(Config::ConfiguredAddr).await?,
|
||||
alice2_primary
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_timestamp(t: &TestContext, addr: &str) -> i64 {
|
||||
t.sql
|
||||
.query_get_value("SELECT add_timestamp FROM transports WHERE addr=?", (addr,))
|
||||
|
||||
@@ -2,5 +2,4 @@ OutBroadcast#Chat#1001: My Channel [1 member(s)]🔇 Icon: e9b6c7a78aa2e4f415644
|
||||
--------------------------------------------------------------------------------
|
||||
Msg#1001: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
|
||||
Msg#1002🔒: Me (Contact#Contact#Self): Channel image changed. [INFO] √
|
||||
Msg#1005🔒: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
OutBroadcast#Chat#1001: Channel [0 member(s)]🔇
|
||||
--------------------------------------------------------------------------------
|
||||
Msg#1001: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
|
||||
Msg#1006🔒: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √
|
||||
Msg#1009🔒: Me (Contact#Contact#Self): hi √
|
||||
Msg#1010🔒: Me (Contact#Contact#Self): You removed member bob@example.net. [INFO] √
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
OutBroadcast#Chat#1001: Channel [0 member(s)]🔇
|
||||
--------------------------------------------------------------------------------
|
||||
Msg#1002: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
|
||||
Msg#1006🔒: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √
|
||||
Msg#1009🔒: Me (Contact#Contact#Self): hi √
|
||||
Msg#1010🔒: Me (Contact#Contact#Self): You removed member bob@example.net. [INFO] √
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user