Compare commits

..

1 Commits

Author SHA1 Message Date
WofWca
ca807dc737 fix: improve connectivity HTML if quota info error
Currently if the provider doesn't support quota info
the HTML displays something like

```
example.com: Connected
Not supported by your provider.
```

It's not clear that "not supported" only refers to quota info.
2026-04-16 13:49:11 +04:00
78 changed files with 1902 additions and 1421 deletions

View File

@@ -20,7 +20,7 @@ permissions: {}
env:
RUSTFLAGS: -Dwarnings
RUST_VERSION: 1.95.0
RUST_VERSION: 1.94.0
# Minimum Supported Rust Version
MSRV: 1.88.0
@@ -40,7 +40,7 @@ jobs:
- run: rustup override set $RUST_VERSION
shell: bash
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: Run rustfmt
run: cargo fmt --all -- --check
- name: Run clippy
@@ -59,7 +59,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: EmbarkStudios/cargo-deny-action@91bf2b620e09e18d6eb78b92e7861937469acedb
- uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979
with:
arguments: --workspace --all-features --locked
command: check
@@ -91,7 +91,7 @@ jobs:
show-progress: false
persist-credentials: false
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: Rustdoc
run: cargo doc --document-private-items --no-deps
@@ -134,7 +134,7 @@ jobs:
shell: bash
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: Install nextest
uses: taiki-e/install-action@85b24a67ef0c632dfefad70b9d5ce8fddb040754
@@ -168,7 +168,7 @@ jobs:
persist-credentials: false
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: Build C library
run: cargo build -p deltachat_ffi
@@ -194,7 +194,7 @@ jobs:
persist-credentials: false
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: Build deltachat-rpc-server
run: cargo build -p deltachat-rpc-server

View File

@@ -382,7 +382,7 @@ jobs:
- name: Publish deltachat-rpc-server to PyPI
if: github.event_name == 'release'
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e
publish_npm_package:
name: Build & Publish npm prebuilds and deltachat-rpc-server

View File

@@ -25,7 +25,7 @@ jobs:
with:
node-version: 18.x
- name: Add Rust cache
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: npm install
working-directory: deltachat-jsonrpc/typescript
run: npm install

View File

@@ -47,4 +47,4 @@ jobs:
name: python-package-distributions
path: dist/
- name: Publish deltachat-rpc-client to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e

View File

@@ -23,4 +23,4 @@ jobs:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2

36
Cargo.lock generated
View File

@@ -1360,7 +1360,7 @@ dependencies = [
"proptest",
"qrcodegen",
"quick-xml",
"rand 0.8.6",
"rand 0.8.5",
"rand 0.9.4",
"ratelimit",
"regex",
@@ -2981,7 +2981,7 @@ dependencies = [
"pin-project",
"pkarr",
"portmapper",
"rand 0.8.6",
"rand 0.8.5",
"rcgen",
"reqwest",
"ring",
@@ -3056,7 +3056,7 @@ dependencies = [
"iroh-metrics",
"n0-future",
"postcard",
"rand 0.8.6",
"rand 0.8.5",
"rand_core 0.6.4",
"serde",
"serde-error",
@@ -3119,7 +3119,7 @@ checksum = "929d5d8fa77d5c304d3ee7cae9aede31f13908bd049f9de8c7c0094ad6f7c535"
dependencies = [
"bytes",
"getrandom 0.2.16",
"rand 0.8.6",
"rand 0.8.5",
"ring",
"rustc-hash",
"rustls",
@@ -3172,7 +3172,7 @@ dependencies = [
"pin-project",
"pkarr",
"postcard",
"rand 0.8.6",
"rand 0.8.5",
"reqwest",
"rustls",
"rustls-webpki 0.102.8",
@@ -3776,7 +3776,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
"rand 0.8.6",
"rand 0.8.5",
"serde",
"smallvec",
"zeroize",
@@ -3933,9 +3933,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl"
version = "0.10.78"
version = "0.10.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222"
checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
@@ -3974,9 +3974,9 @@ dependencies = [
[[package]]
name = "openssl-sys"
version = "0.9.114"
version = "0.9.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6"
checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07"
dependencies = [
"cc",
"libc",
@@ -4206,7 +4206,7 @@ dependencies = [
"p256",
"p384",
"p521",
"rand 0.8.6",
"rand 0.8.5",
"regex",
"replace_with",
"ripemd",
@@ -4453,7 +4453,7 @@ dependencies = [
"nested_enum_utils",
"netwatch",
"num_enum",
"rand 0.8.6",
"rand 0.8.5",
"serde",
"smallvec",
"snafu",
@@ -4776,9 +4776,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.8.6"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha 0.3.1",
@@ -5164,7 +5164,7 @@ dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki 0.103.13",
"rustls-webpki 0.103.10",
"subtle",
"zeroize",
]
@@ -5201,9 +5201,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"ring",
"rustls-pki-types",
@@ -5870,7 +5870,7 @@ dependencies = [
"hex",
"parking_lot",
"pnet_packet",
"rand 0.8.6",
"rand 0.8.5",
"socket2 0.5.9",
"thiserror 1.0.69",
"tokio",

View File

@@ -390,9 +390,27 @@ char* dc_get_blobdir (const dc_context_t* context);
/**
* Configure the context. The configuration is handled by key=value pairs as:
*
* - `configured_addr` = Email address in use.
* - `addr` = Email address to use for configuration.
* If dc_configure() fails this is not the email address actually in use.
* Use `configured_addr` to find out the email address actually in use.
* - `configured_addr` = Email address actually in use.
* Unless for testing, do not set this value using dc_set_config().
* Instead, set `addr` and call dc_configure().
* - `mail_server` = IMAP-server, guessed if left out
* - `mail_user` = IMAP-username, guessed if left out
* - `mail_pw` = IMAP-password (always needed)
* - `mail_port` = IMAP-port, guessed if left out
* - `mail_security`= IMAP-socket, one of @ref DC_SOCKET, defaults to #DC_SOCKET_AUTO
* - `send_server` = SMTP-server, guessed if left out
* - `send_user` = SMTP-user, guessed if left out
* - `send_pw` = SMTP-password, guessed if left out
* - `send_port` = SMTP-port, guessed if left out
* - `send_security`= SMTP-socket, one of @ref DC_SOCKET, defaults to #DC_SOCKET_AUTO
* - `server_flags` = IMAP-/SMTP-flags as a combination of @ref DC_LP flags, guessed if left out
* - `proxy_enabled` = Proxy enabled. Disabled by default.
* - `proxy_url` = Proxy URL. May contain multiple URLs separated by newline, but only the first one is used.
* - `imap_certificate_checks` = how to check IMAP certificates, one of the @ref DC_CERTCK flags, defaults to #DC_CERTCK_AUTO (0)
* - `smtp_certificate_checks` = deprecated option, should be set to the same value as `imap_certificate_checks` but ignored by the new core
* - `displayname` = Own name to use when sending messages. MUAs are allowed to spread this way e.g. using CC, defaults to empty
* - `selfstatus` = Own status to display, e.g. in e-mail footers, defaults to empty
* - `selfavatar` = File containing avatar. Will immediately be copied to the
@@ -408,6 +426,14 @@ char* dc_get_blobdir (const dc_context_t* context);
* 1=send a copy of outgoing messages to self (default).
* Sending messages to self is needed for a proper multi-account setup,
* however, on the other hand, may lead to unwanted notifications in non-delta clients.
* - `mvbox_move` = 1=detect chat messages,
* move them to the `DeltaChat` folder,
* and watch the `DeltaChat` folder for updates (default),
* 0=do not move chat-messages
* - `only_fetch_mvbox` = 1=Do not fetch messages from folders other than the
* `DeltaChat` folder. Messages will still be fetched from the
* spam folder.
* 0=watch all folders normally (default)
* - `show_emails` = DC_SHOW_EMAILS_OFF (0)=
* show direct replies to chats only,
* DC_SHOW_EMAILS_ACCEPTED_CONTACTS (1)=
@@ -495,27 +521,6 @@ char* dc_get_blobdir (const dc_context_t* context);
* 1 = Contacts (default, does not include contact requests),
* 2 = Nobody (calls never result in a notification).
*
* Also, there are configs that are only needed
* if you want to use the deprecated dc_configure() API, such as:
*
* - `addr` = Email address to use for configuration.
* If dc_configure() fails this is not the email address actually in use.
* Use `configured_addr` to find out the email address actually in use.
* - `mail_server` = IMAP-server, guessed if left out
* - `mail_user` = IMAP-username, guessed if left out
* - `mail_pw` = IMAP-password (always needed)
* - `mail_port` = IMAP-port, guessed if left out
* - `mail_security`= IMAP-socket, one of @ref DC_SOCKET, defaults to #DC_SOCKET_AUTO
* - `send_server` = SMTP-server, guessed if left out
* - `send_user` = SMTP-user, guessed if left out
* - `send_pw` = SMTP-password, guessed if left out
* - `send_port` = SMTP-port, guessed if left out
* - `send_security`= SMTP-socket, one of @ref DC_SOCKET, defaults to #DC_SOCKET_AUTO
* - `server_flags` = IMAP-/SMTP-flags as a combination of @ref DC_LP flags, guessed if left out
* - `proxy_enabled` = Proxy enabled. Disabled by default.
* - `proxy_url` = Proxy URL. May contain multiple URLs separated by newline, but only the first one is used.
* - `imap_certificate_checks` = how to check IMAP and SMTP certificates, one of the @ref DC_CERTCK flags, defaults to #DC_CERTCK_AUTO (0)
* If you want to retrieve a value, use dc_get_config().
*
* @memberof dc_context_t
@@ -702,12 +707,6 @@ int dc_get_push_state (dc_context_t* context);
/**
* Configure a context.
*
* This way of configuring a context is deprecated,
* and does not allow to configure multiple transports.
* If you can, use the JSON-RPC API (../deltachat-jsonrpc/src/api.rs)
* `add_or_update_transport()`/`addOrUpdateTransport()` instead.
*
* During configuration IO must not be started,
* if needed stop IO using dc_accounts_stop_io() or dc_stop_io() first.
* If the context is already configured,
@@ -2641,8 +2640,6 @@ dc_lot_t* dc_check_qr (dc_context_t* context, const char*
* so that the QR code is useful also without Delta Chat being installed
* or can be passed to contacts through other channels.
*
* To reset invitations, pass the link to dc_set_config_from_qr().
*
* @memberof dc_context_t
* @param context The context object.
* @param chat_id If set to a group-chat-id,
@@ -4245,8 +4242,6 @@ char* dc_msg_get_webxdc_blob (const dc_msg_t* msg, const char*
* true if the Webxdc should get internet access;
* this is the case i.e. for experimental maps integration.
* - self_addr: address to be used for `window.webxdc.selfAddr` in JS land.
* - is_app_sender: Define if the local user is the one who initially shared the webxdc application in the chat.
* - is_broadcast: Define if the app runs in a broadcasting context.
* - send_update_interval: Milliseconds to wait before calling `sendUpdate()` again since the last call.
* Should be exposed to `webxdc.sendUpdateInterval` in JS land.
* - send_update_max_size: Maximum number of bytes accepted for a serialized update object.
@@ -5815,7 +5810,7 @@ int64_t dc_lot_get_timestamp (const dc_lot_t* lot);
* These constants configure TLS certificate checks for IMAP and SMTP connections.
*
* These constants are set via dc_set_config()
* using key "imap_certificate_checks".
* using keys "imap_certificate_checks" and "smtp_certificate_checks".
*
* @addtogroup DC_CERTCK
* @{

View File

@@ -318,15 +318,6 @@ impl CommandApi {
Ok(())
}
/// Requests to clear storage on all chatmail relays.
///
/// I/O must be started for this request to take effect.
async fn clear_all_relay_storage(&self, account_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?;
ctx.clear_all_relay_storage().await?;
Ok(())
}
/// Get top-level info for an account.
async fn get_account_info(&self, account_id: u32) -> Result<Account> {
let context_option = self.accounts.read().await.get_account(account_id);
@@ -883,8 +874,6 @@ impl CommandApi {
/// Get QR code text that will offer a [SecureJoin](https://securejoin.delta.chat/) invitation.
///
/// To reset invitations, pass the link to `set_config_from_qr()`.
///
/// If `chat_id` is a group chat ID, SecureJoin QR code for the group is returned.
/// If `chat_id` is unset, setup contact QR code is returned.
async fn get_chat_securejoin_qr_code(
@@ -2388,12 +2377,12 @@ impl CommandApi {
Ok(message_id.to_u32())
}
/// Sends a reaction to message.
/// Send a reaction to message.
///
/// A reaction is a string that represents an emoji.
/// You can call this function again to change the emoji;
/// the last sent reaction overrides all previously sent reactions.
/// It is possible to remove the reaction by sending an empty string.
/// Reaction is a string of emojis separated by spaces. Reaction to a
/// single message can be sent multiple times. The last reaction
/// received overrides all previously received reactions. It is
/// possible to remove all reactions by sending an empty string.
async fn send_reaction(
&self,
account_id: u32,

View File

@@ -33,12 +33,6 @@ pub struct EnteredLoginParam {
/// Imap server port.
pub imap_port: Option<u16>,
/// IMAP server folder.
///
/// Defaults to "INBOX" if not set.
/// Should not be an empty string.
pub imap_folder: Option<String>,
/// Imap socket security.
pub imap_security: Option<Socket>,
@@ -91,7 +85,6 @@ impl From<dc::EnteredLoginParam> for EnteredLoginParam {
password: param.imap.password,
imap_server: param.imap.server.into_option(),
imap_port: param.imap.port.into_option(),
imap_folder: param.imap.folder.into_option(),
imap_security: imap_security.into_option(),
imap_user: param.imap.user.into_option(),
smtp_server: param.smtp.server.into_option(),
@@ -111,15 +104,14 @@ impl TryFrom<EnteredLoginParam> for dc::EnteredLoginParam {
fn try_from(param: EnteredLoginParam) -> Result<Self> {
Ok(Self {
addr: param.addr,
imap: dc::EnteredImapLoginParam {
imap: dc::EnteredServerLoginParam {
server: param.imap_server.unwrap_or_default(),
port: param.imap_port.unwrap_or_default(),
folder: param.imap_folder.unwrap_or_default(),
security: param.imap_security.unwrap_or_default().into(),
user: param.imap_user.unwrap_or_default(),
password: param.password,
},
smtp: dc::EnteredSmtpLoginParam {
smtp: dc::EnteredServerLoginParam {
server: param.smtp_server.unwrap_or_default(),
port: param.smtp_port.unwrap_or_default(),
security: param.smtp_security.unwrap_or_default().into(),

View File

@@ -24,8 +24,6 @@ pub struct JsonrpcReaction {
#[serde(rename = "Reactions", rename_all = "camelCase")]
pub struct JsonrpcReactions {
/// Map from a contact to it's reaction to message.
/// There is only a single reaction per contact,
/// but this contains a list of reactions for historical reasons.
reactions_by_contact: BTreeMap<u32, Vec<String>>,
/// Unique reactions and their count, sorted in descending order.
reactions: Vec<JsonrpcReaction>,
@@ -33,16 +31,27 @@ pub struct JsonrpcReactions {
impl From<Reactions> for JsonrpcReactions {
fn from(reactions: Reactions) -> Self {
let reactions_by_contact: BTreeMap<u32, Vec<String>> = reactions
.iter()
.map(|(key, value)| (key.to_u32(), vec![value.as_str().to_string()]))
.collect();
let self_reaction = reactions_by_contact.get(&ContactId::SELF.to_u32());
let mut reactions_by_contact: BTreeMap<u32, Vec<String>> = BTreeMap::new();
for contact_id in reactions.contacts() {
let reaction = reactions.get(contact_id);
if reaction.is_empty() {
continue;
}
let emojis: Vec<String> = reaction
.emojis()
.into_iter()
.map(|emoji| emoji.to_owned())
.collect();
reactions_by_contact.insert(contact_id.to_u32(), emojis.clone());
}
let self_reactions = reactions_by_contact.get(&ContactId::SELF.to_u32());
let mut reactions_v = Vec::new();
for (emoji, count) in reactions.emoji_sorted_by_frequency() {
let is_from_self = if let Some(self_reaction) = self_reaction {
self_reaction.contains(&emoji)
let is_from_self = if let Some(self_reactions) = self_reactions {
self_reactions.contains(&emoji)
} else {
false
};

View File

@@ -37,10 +37,6 @@ pub struct WebxdcMessageInfo {
internet_access: bool,
/// Address to be used for `window.webxdc.selfAddr` in JS land.
self_addr: String,
/// Define if the local user is the one who initially shared the webxdc application in the chat.
is_app_sender: bool,
/// Define if the app runs in a broadcasting context.
is_broadcast: bool,
/// Milliseconds to wait before calling `sendUpdate()` again since the last call.
/// Should be exposed to `window.sendUpdateInterval` in JS land.
send_update_interval: usize,
@@ -64,8 +60,6 @@ impl WebxdcMessageInfo {
request_integration: _,
internet_access,
self_addr,
is_app_sender,
is_broadcast,
send_update_interval,
send_update_max_size,
} = message.get_webxdc_info(context).await?;
@@ -78,8 +72,6 @@ impl WebxdcMessageInfo {
source_code_url: maybe_empty_string_to_option(source_code_url),
internet_access,
self_addr,
is_app_sender,
is_broadcast,
send_update_interval,
send_update_max_size,
})

View File

@@ -85,7 +85,7 @@ mod tests {
assert_eq!(result, response.to_owned());
}
{
let request = r#"{"jsonrpc":"2.0","method":"batch_set_config","id":2,"params":[1,{"addr":"","mail_user":"","mail_pw":"","mail_server":"","mail_port":"","mail_security":"","imap_certificate_checks":"","send_user":"","send_pw":"","send_server":"","send_port":"","send_security":""}]}"#;
let request = r#"{"jsonrpc":"2.0","method":"batch_set_config","id":2,"params":[1,{"addr":"","mail_user":"","mail_pw":"","mail_server":"","mail_port":"","mail_security":"","imap_certificate_checks":"","send_user":"","send_pw":"","send_server":"","send_port":"","send_security":"","smtp_certificate_checks":""}]}"#;
let response = r#"{"jsonrpc":"2.0","id":2,"result":null}"#;
session.handle_incoming(request).await;
let result = receiver.recv().await?;

View File

@@ -25,14 +25,7 @@ class Message:
return self.account._rpc
def send_reaction(self, *reaction: str) -> "Message":
"""
Sends a reaction to message.
A reaction is a string that represents an emoji.
You can call this function again to change the emoji;
the last sent reaction overrides all previously sent reactions.
It is possible to remove the reaction by sending an empty string.
"""
"""Send a reaction to this message."""
msg_id = self._rpc.send_reaction(self.account.id, self.id, reaction)
return Message(self.account, msg_id)

View File

@@ -1,26 +1,104 @@
import logging
import re
import time
import pytest
from imap_tools import AND, U
from deltachat_rpc_client import EventType
from deltachat_rpc_client import Contact, EventType, Message
def test_moved_markseen(acfactory, direct_imap, log):
"""Test that message already moved to DeltaChat folder is marked as seen."""
ac1 = acfactory.get_online_account()
def test_move_works(acfactory, direct_imap):
ac1, ac2 = acfactory.get_online_accounts(2)
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("DeltaChat")
ac2.set_config("mvbox_move", "1")
ac2.bring_online()
chat = ac1.create_chat(ac2)
chat.send_text("message1")
# Message is moved to the movebox
ac2.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
# Message is downloaded
msg = ac2.wait_for_incoming_msg().get_snapshot()
assert msg.text == "message1"
def test_reactions_for_a_reordering_move(acfactory, direct_imap):
"""When a batch of messages is moved from Inbox to DeltaChat folder with a single MOVE command,
their UIDs may be reordered (e.g. Gmail is known for that) which led to that messages were
processed by receive_imf in the wrong order, and, particularly, reactions were processed before
messages they refer to and thus dropped.
"""
(ac1,) = acfactory.get_online_accounts(1)
addr, password = acfactory.get_credentials()
ac2 = acfactory.get_unconfigured_account()
ac2.add_or_update_transport({"addr": addr, "password": password})
ac2.bring_online()
log.section("ac2: creating DeltaChat folder")
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("DeltaChat")
ac2.set_config("mvbox_move", "1")
assert ac2.is_configured()
ac2.bring_online()
chat1 = acfactory.get_accepted_chat(ac1, ac2)
ac2.stop_io()
logging.info("sending message + reaction from ac1 to ac2")
msg1 = chat1.send_text("hi")
msg1.wait_until_delivered()
# It's is sad, but messages must differ in their INTERNALDATEs to be processed in the correct
# order by DC, and most (if not all) mail servers provide only seconds precision.
time.sleep(1.1)
react_str = "\N{THUMBS UP SIGN}"
msg1.send_reaction(react_str).wait_until_delivered()
logging.info("moving messages to ac2's DeltaChat folder in the reverse order")
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.connect()
for uid in sorted([m.uid for m in ac2_direct_imap.get_all_messages()], reverse=True):
ac2_direct_imap.conn.move(uid, "DeltaChat")
logging.info("receiving messages by ac2")
ac2.start_io()
msg2 = Message(ac2, ac2.wait_for_reactions_changed().msg_id)
assert msg2.get_snapshot().text == msg1.get_snapshot().text
reactions = msg2.get_reactions()
contacts = [Contact(ac2, int(i)) for i in reactions.reactions_by_contact]
assert len(contacts) == 1
assert contacts[0].get_snapshot().address == ac1.get_config("addr")
assert list(reactions.reactions_by_contact.values())[0] == [react_str]
def test_move_works_on_self_sent(acfactory, direct_imap):
ac1, ac2 = acfactory.get_online_accounts(2)
# Create and enable movebox.
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.create_folder("DeltaChat")
ac1.set_config("mvbox_move", "1")
ac1.set_config("bcc_self", "1")
ac1.bring_online()
chat = ac1.create_chat(ac2)
chat.send_text("message1")
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
chat.send_text("message2")
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
chat.send_text("message3")
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
def test_moved_markseen(acfactory, direct_imap):
"""Test that message already moved to DeltaChat folder is marked as seen."""
ac1, ac2 = acfactory.get_online_accounts(2)
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("DeltaChat")
ac2.set_config("mvbox_move", "1")
ac2.set_config("delete_server_after", "0")
ac2.set_config("sync_msgs", "0") # Do not send a sync message when accepting a contact request.
ac2.add_or_update_transport({"addr": addr, "password": password, "imapFolder": "DeltaChat"})
ac2.bring_online()
ac2.stop_io()
@@ -30,7 +108,6 @@ def test_moved_markseen(acfactory, direct_imap, log):
idle2.wait_for_new_message()
# Emulate moving of the message to DeltaChat folder by Sieve rule.
log.section("ac2: moving message into DeltaChat folder")
ac2_direct_imap.conn.move(["*"], "DeltaChat")
ac2_direct_imap.select_folder("DeltaChat")
assert len(list(ac2_direct_imap.conn.fetch("*", mark_seen=False))) == 1
@@ -54,11 +131,17 @@ def test_moved_markseen(acfactory, direct_imap, log):
assert len(list(ac2_direct_imap.conn.fetch(AND(seen=True, uid=U(1, "*")), mark_seen=False))) == 1
def test_markseen_message_and_mdn(acfactory, direct_imap):
@pytest.mark.parametrize("mvbox_move", [True, False])
def test_markseen_message_and_mdn(acfactory, direct_imap, mvbox_move):
ac1, ac2 = acfactory.get_online_accounts(2)
for ac in ac1, ac2:
ac.set_config("delete_server_after", "0")
if mvbox_move:
ac_direct_imap = direct_imap(ac)
ac_direct_imap.create_folder("DeltaChat")
ac.set_config("mvbox_move", "1")
ac.bring_online()
# Do not send BCC to self, we only want to test MDN on ac1.
ac1.set_config("bcc_self", "0")
@@ -67,7 +150,10 @@ def test_markseen_message_and_mdn(acfactory, direct_imap):
msg = ac2.wait_for_incoming_msg()
msg.mark_seen()
rex = re.compile("Marked messages [0-9]+ in folder INBOX as seen.")
if mvbox_move:
rex = re.compile("Marked messages [0-9]+ in folder DeltaChat as seen.")
else:
rex = re.compile("Marked messages [0-9]+ in folder INBOX as seen.")
for ac in ac1, ac2:
while True:
@@ -75,11 +161,12 @@ def test_markseen_message_and_mdn(acfactory, direct_imap):
if event.kind == EventType.INFO and rex.search(event.msg):
break
folder = "mvbox" if mvbox_move else "inbox"
ac1_direct_imap = direct_imap(ac1)
ac2_direct_imap = direct_imap(ac2)
ac1_direct_imap.select_folder("INBOX")
ac2_direct_imap.select_folder("INBOX")
ac1_direct_imap.select_config_folder(folder)
ac2_direct_imap.select_config_folder(folder)
# Check that the mdn is marked as seen
assert len(list(ac1_direct_imap.conn.fetch(AND(seen=True), mark_seen=False))) == 1

View File

@@ -9,6 +9,10 @@ def test_add_second_address(acfactory) -> None:
account = acfactory.new_configured_account()
assert len(account.list_transports()) == 1
# When the first transport is created,
# mvbox_move and only_fetch_mvbox should be disabled.
assert account.get_config("mvbox_move") == "0"
assert account.get_config("only_fetch_mvbox") == "0"
assert account.get_config("show_emails") == "2"
qr = acfactory.get_account_qr()
@@ -28,10 +32,32 @@ def test_add_second_address(acfactory) -> None:
account.delete_transport(second_addr)
assert len(account.list_transports()) == 2
# Enabling mvbox_move or only_fetch_mvbox
# is not allowed when multi-transport is enabled.
for option in ["mvbox_move", "only_fetch_mvbox"]:
with pytest.raises(JsonRpcError):
account.set_config(option, "1")
# show_emails does not matter for multi-relay, can be set to anything
account.set_config("show_emails", "0")
@pytest.mark.parametrize("key", ["mvbox_move", "only_fetch_mvbox"])
def test_no_second_transport_with_mvbox(acfactory, key) -> None:
"""Test that second transport cannot be configured if mvbox is used."""
account = acfactory.new_configured_account()
assert len(account.list_transports()) == 1
assert account.get_config("mvbox_move") == "0"
assert account.get_config("only_fetch_mvbox") == "0"
qr = acfactory.get_account_qr()
account.set_config(key, "1")
with pytest.raises(JsonRpcError):
account.add_transport_from_qr(qr)
def test_second_transport_without_classic_emails(acfactory) -> None:
"""Test that second transport can be configured if classic emails are not fetched."""
account = acfactory.new_configured_account()
@@ -121,13 +147,44 @@ def test_download_on_demand(acfactory) -> None:
assert msg.get_snapshot().download_state == dstate
@pytest.mark.parametrize("is_chatmail", ["0", "1"])
def test_mvbox_move_first_transport(acfactory, is_chatmail) -> None:
"""Test that mvbox_move is disabled by default even for non-chatmail accounts.
Disabling mvbox_move is required to be able to setup a second transport.
"""
account = acfactory.get_unconfigured_account()
account.set_config("fix_is_chatmail", "1")
account.set_config("is_chatmail", is_chatmail)
# The default value when the setting is unset is "1".
# This is not changed for compatibility with old databases
# imported from backups.
assert account.get_config("mvbox_move") == "1"
qr = acfactory.get_account_qr()
account.add_transport_from_qr(qr)
# Once the first transport is set up,
# mvbox_move is disabled.
assert account.get_config("mvbox_move") == "0"
assert account.get_config("is_chatmail") == is_chatmail
def test_reconfigure_transport(acfactory) -> None:
"""Test that reconfiguring the transport works."""
"""Test that reconfiguring the transport works
even if settings not supported for multi-transport
like mvbox_move are enabled."""
account = acfactory.get_online_account()
account.set_config("mvbox_move", "1")
[transport] = account.list_transports()
account.add_or_update_transport(transport)
# Reconfiguring the transport should not reset
# the settings as if when configuring the first transport.
assert account.get_config("mvbox_move") == "1"
def test_transport_synchronization(acfactory, log) -> None:
"""Test synchronization of transports between devices."""

View File

@@ -18,8 +18,6 @@ def test_webxdc(acfactory) -> None:
"sourceCodeUrl": None,
"summary": None,
"selfAddr": webxdc_info["selfAddr"],
"isAppSender": False,
"isBroadcast": False,
"sendUpdateInterval": 1000,
"sendUpdateMaxSize": 18874368,
}

View File

@@ -23,17 +23,7 @@ ignore = [
# it is a transitive dependency of iroh 0.35.0
# which depends on ^0.102.
# <https://rustsec.org/advisories/RUSTSEC-2026-0049>
# <https://rustsec.org/advisories/RUSTSEC-2026-0098>
# <https://rustsec.org/advisories/RUSTSEC-2026-0099>
"RUSTSEC-2026-0049",
"RUSTSEC-2026-0098",
"RUSTSEC-2026-0099",
# Panic in CRL signature checks.
# We do not check CRL and cannot update rustls-webpki 0.102.8
# which is a dependency of iroh 0.35.0.
# <https://rustsec.org/advisories/RUSTSEC-2026-0104>
"RUSTSEC-2026-0104"
]
[bans]

View File

@@ -433,6 +433,7 @@ class ACFactory:
if self.pytestconfig.getoption("--strict-tls"):
# Enable strict certificate checks for online accounts
configdict["imap_certificate_checks"] = str(const.DC_CERTCK_STRICT)
configdict["smtp_certificate_checks"] = str(const.DC_CERTCK_STRICT)
assert "addr" in configdict and "mail_pw" in configdict
return configdict
@@ -504,6 +505,7 @@ class ACFactory:
"addr": cloned_from.get_config("addr"),
"mail_pw": cloned_from.get_config("mail_pw"),
"imap_certificate_checks": cloned_from.get_config("imap_certificate_checks"),
"smtp_certificate_checks": cloned_from.get_config("smtp_certificate_checks"),
}
configdict.update(kwargs)
ac = self._get_cached_account(addr=configdict["addr"]) if cache else None
@@ -520,6 +522,7 @@ class ACFactory:
ac = self.get_unconfigured_account()
assert "addr" in configdict and "mail_pw" in configdict, configdict
configdict.setdefault("bcc_self", False)
configdict.setdefault("mvbox_move", False)
configdict.setdefault("sync_msgs", False)
configdict.setdefault("delete_server_after", 0)
ac.update_config(configdict)

View File

@@ -52,19 +52,19 @@ class TestOfflineAccountBasic:
def test_set_config_int_conversion(self, acfactory):
ac1 = acfactory.get_unconfigured_account()
ac1.set_config("bcc_self", False)
assert ac1.get_config("bcc_self") == "0"
ac1.set_config("bcc_self", True)
assert ac1.get_config("bcc_self") == "1"
ac1.set_config("bcc_self", 0)
assert ac1.get_config("bcc_self") == "0"
ac1.set_config("bcc_self", 1)
assert ac1.get_config("bcc_self") == "1"
ac1.set_config("mvbox_move", False)
assert ac1.get_config("mvbox_move") == "0"
ac1.set_config("mvbox_move", True)
assert ac1.get_config("mvbox_move") == "1"
ac1.set_config("mvbox_move", 0)
assert ac1.get_config("mvbox_move") == "0"
ac1.set_config("mvbox_move", 1)
assert ac1.get_config("mvbox_move") == "1"
def test_update_config(self, acfactory):
ac1 = acfactory.get_unconfigured_account()
ac1.update_config({"bcc_self": True})
assert ac1.get_config("bcc_self") == "1"
ac1.update_config({"mvbox_move": False})
assert ac1.get_config("mvbox_move") == "0"
def test_has_bccself(self, acfactory):
ac1 = acfactory.get_unconfigured_account()

View File

@@ -48,3 +48,19 @@ the build machine (ask your friendly sysadmin on #deltachat Libera Chat) to type
This will **rsync** your current checkout to the remote build machine
(no need to commit before) and then run either rust or python tests.
# coredeps Dockerfile
`coredeps/Dockerfile` specifies an image that contains all
of Delta Chat's core dependencies. It is used to
build python wheels (binary packages for Python).
You can build the docker images yourself locally
to avoid the relatively large download:
cd scripts # where all CI things are
docker build -t deltachat/coredeps coredeps
Additionally, you can install qemu and build arm64 docker image on x86\_64 machine:
apt-get install qemu binfmt-support qemu-user-static
docker build -t deltachat/coredeps-arm64 --build-arg BASEIMAGE=quay.io/pypa/manylinux2014_aarch64 coredeps

View File

@@ -0,0 +1,26 @@
# Concourse CI pipeline
`docs_wheels.yml` is a pipeline for [Concourse CI](https://concourse-ci.org/)
that builds C documentation, Python documentation, Python wheels for `x86_64`
and `aarch64` and Python source packages, and uploads them.
To setup the pipeline run
```
fly -t <your-target> set-pipeline -c docs_wheels.yml -p docs_wheels -l secret.yml
```
where `secret.yml` contains the following secrets:
```
c.delta.chat:
private_key: |
-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----
devpi:
login: dc
password: ...
```
Secrets can be read from the password manager:
```
fly -t b1 set-pipeline -c docs_wheels.yml -p docs_wheels -l <(pass show delta/b1.delta.chat/secret.yml)
```

View File

@@ -0,0 +1,305 @@
resources:
- name: deltachat-core-rust
type: git
icon: github
source:
branch: main
uri: https://github.com/chatmail/core.git
- name: deltachat-core-rust-release
type: git
icon: github
source:
branch: main
uri: https://github.com/chatmail/core.git
tag_filter: "v*"
jobs:
- name: python-x86_64
plan:
- get: deltachat-core-rust
- get: deltachat-core-rust-release
trigger: true
# Build manylinux image with additional dependencies
- task: build-coredeps
privileged: true
config:
inputs:
# Building the latest, not tagged coredeps
- name: deltachat-core-rust
image_resource:
source:
repository: concourse/oci-build-task
type: registry-image
outputs:
- name: coredeps-image
path: image
params:
CONTEXT: deltachat-core-rust/scripts/coredeps
UNPACK_ROOTFS: "true"
BUILD_ARG_BASEIMAGE: quay.io/pypa/manylinux2014_x86_64
platform: linux
caches:
- path: cache
run:
path: build
# Use built image to build python wheels
- task: build-wheels
image: coredeps-image
config:
inputs:
- name: deltachat-core-rust-release
path: .
outputs:
# Binary wheels
- name: py-wheels
path: ./python/.docker-tox/wheelhouse/
platform: linux
run:
path: bash
args:
- -exc
- |
scripts/run_all.sh
# Upload x86_64 wheels and source packages
- task: upload-wheels
config:
inputs:
- name: py-wheels
image_resource:
type: registry-image
source:
repository: debian
platform: linux
run:
path: sh
args:
- -ec
- |
apt-get update -y
apt-get install -y --no-install-recommends python3-pip python3-setuptools python3-venv
python3 -m venv env
env/bin/pip install --upgrade pip
env/bin/pip install devpi
env/bin/devpi use https://m.devpi.net/dc/master
env/bin/devpi login ((devpi.login)) --password ((devpi.password))
env/bin/devpi upload py-wheels/*manylinux201*
- name: python-aarch64
plan:
- get: deltachat-core-rust
- get: deltachat-core-rust-release
trigger: true
# Build manylinux image with additional dependencies
- task: build-coredeps
privileged: true
config:
inputs:
# Building the latest, not tagged coredeps
- name: deltachat-core-rust
image_resource:
source:
repository: concourse/oci-build-task
type: registry-image
outputs:
- name: coredeps-image
path: image
params:
CONTEXT: deltachat-core-rust/scripts/coredeps
UNPACK_ROOTFS: "true"
BUILD_ARG_BASEIMAGE: quay.io/pypa/manylinux2014_aarch64
platform: linux
caches:
- path: cache
run:
path: build
# Use built image to build python wheels
- task: build-wheels
image: coredeps-image
config:
inputs:
- name: deltachat-core-rust-release
path: .
outputs:
- name: py-wheels
path: ./python/.docker-tox/wheelhouse/
platform: linux
run:
path: bash
args:
- -exc
- |
scripts/run_all.sh
# Upload aarch64 wheels
- task: upload-wheels
config:
inputs:
- name: py-wheels
image_resource:
type: registry-image
source:
repository: debian
platform: linux
run:
path: sh
args:
- -ec
- |
apt-get update -y
apt-get install -y --no-install-recommends python3-pip python3-setuptools python3-venv
python3 -m venv env
env/bin/pip install --upgrade pip
env/bin/pip install devpi
env/bin/devpi use https://m.devpi.net/dc/master
env/bin/devpi login ((devpi.login)) --password ((devpi.password))
env/bin/devpi upload py-wheels/*manylinux201*
- name: python-musl-x86_64
plan:
- get: deltachat-core-rust
- get: deltachat-core-rust-release
trigger: true
# Build manylinux image with additional dependencies
- task: build-coredeps
privileged: true
config:
inputs:
# Building the latest, not tagged coredeps
- name: deltachat-core-rust
image_resource:
source:
repository: concourse/oci-build-task
type: registry-image
outputs:
- name: coredeps-image
path: image
params:
CONTEXT: deltachat-core-rust/scripts/coredeps
UNPACK_ROOTFS: "true"
BUILD_ARG_BASEIMAGE: quay.io/pypa/musllinux_1_1_x86_64
platform: linux
caches:
- path: cache
run:
path: build
# Use built image to build python wheels
- task: build-wheels
image: coredeps-image
config:
inputs:
- name: deltachat-core-rust-release
path: .
outputs:
- name: py-wheels
path: ./python/.docker-tox/wheelhouse/
platform: linux
run:
path: bash
args:
- -exc
- |
scripts/run_all.sh
# Upload musl x86_64 wheels
- task: upload-wheels
config:
inputs:
- name: py-wheels
image_resource:
type: registry-image
source:
repository: debian
platform: linux
run:
path: sh
args:
- -ec
- |
apt-get update -y
apt-get install -y --no-install-recommends python3-pip python3-setuptools python3-venv
python3 -m venv env
env/bin/pip install --upgrade pip
env/bin/pip install devpi
env/bin/devpi use https://m.devpi.net/dc/master
env/bin/devpi login ((devpi.login)) --password ((devpi.password))
env/bin/devpi upload py-wheels/*musllinux_1_1_x86_64*
- name: python-musl-aarch64
plan:
- get: deltachat-core-rust
- get: deltachat-core-rust-release
trigger: true
# Build manylinux image with additional dependencies
- task: build-coredeps
privileged: true
config:
inputs:
# Building the latest, not tagged coredeps
- name: deltachat-core-rust
image_resource:
source:
repository: concourse/oci-build-task
type: registry-image
outputs:
- name: coredeps-image
path: image
params:
CONTEXT: deltachat-core-rust/scripts/coredeps
UNPACK_ROOTFS: "true"
BUILD_ARG_BASEIMAGE: quay.io/pypa/musllinux_1_1_aarch64
platform: linux
caches:
- path: cache
run:
path: build
# Use built image to build python wheels
- task: build-wheels
image: coredeps-image
config:
inputs:
- name: deltachat-core-rust-release
path: .
outputs:
- name: py-wheels
path: ./python/.docker-tox/wheelhouse/
platform: linux
run:
path: bash
args:
- -exc
- |
scripts/run_all.sh
# Upload musl aarch64 wheels
- task: upload-wheels
config:
inputs:
- name: py-wheels
image_resource:
type: registry-image
source:
repository: debian
platform: linux
run:
path: sh
args:
- -ec
- |
apt-get update -y
apt-get install -y --no-install-recommends python3-pip python3-setuptools python3-venv
python3 -m venv env
env/bin/pip install --upgrade pip
env/bin/pip install devpi
env/bin/devpi use https://m.devpi.net/dc/master
env/bin/devpi login ((devpi.login)) --password ((devpi.password))
env/bin/devpi upload py-wheels/*musllinux_1_1_aarch64*

View File

@@ -0,0 +1,9 @@
ARG BASEIMAGE=quay.io/pypa/manylinux2014_x86_64
#ARG BASEIMAGE=quay.io/pypa/musllinux_1_1_x86_64
#ARG BASEIMAGE=quay.io/pypa/manylinux2014_aarch64
FROM $BASEIMAGE
RUN pipx install tox
COPY install-rust.sh /scripts/
RUN /scripts/install-rust.sh
RUN if command -v yum; then yum install -y perl-IPC-Cmd; fi

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
# Install Rust
#
# Path from https://forge.rust-lang.org/infra/other-installation-methods.html
#
# Avoid using rustup here as it depends on reading /proc/self/exe and
# has problems running under QEMU.
RUST_VERSION=1.94.0
ARCH="$(uname -m)"
test -f "/lib/libc.musl-$ARCH.so.1" && LIBC=musl || LIBC=gnu
curl "https://static.rust-lang.org/dist/rust-${RUST_VERSION}-$ARCH-unknown-linux-$LIBC.tar.gz" | tar xz
cd "rust-${RUST_VERSION}-$ARCH-unknown-linux-$LIBC"
./install.sh --prefix=/usr --components=rustc,cargo,"rust-std-$ARCH-unknown-linux-$LIBC"
rustc --version
cd ..
rm -fr "rust-${RUST_VERSION}-$ARCH-unknown-linux-$LIBC"

View File

@@ -6,7 +6,7 @@ set -euo pipefail
export TZ=UTC
# Provider database revision.
REV=ad097ee40579c884e7757de2d3bb0a51f481a32a
REV=996c4bc82be5a7404f70b185ff062da33bfa98d9
CORE_ROOT="$PWD"
TMP="$(mktemp -d)"

View File

@@ -23,9 +23,8 @@ use crate::chatlist_events;
use crate::color::str_to_color;
use crate::config::Config;
use crate::constants::{
self, Blocked, Chattype, DC_CHAT_ID_ALLDONE_HINT, DC_CHAT_ID_ARCHIVED_LINK,
DC_CHAT_ID_LAST_SPECIAL, DC_CHAT_ID_TRASH, DC_RESEND_USER_AVATAR_DAYS, EDITED_PREFIX,
TIMESTAMP_SENT_TOLERANCE,
Blocked, Chattype, DC_CHAT_ID_ALLDONE_HINT, DC_CHAT_ID_ARCHIVED_LINK, DC_CHAT_ID_LAST_SPECIAL,
DC_CHAT_ID_TRASH, DC_RESEND_USER_AVATAR_DAYS, EDITED_PREFIX, TIMESTAMP_SENT_TOLERANCE,
};
use crate::contact::{self, Contact, ContactId, Origin};
use crate::context::Context;
@@ -35,7 +34,7 @@ use crate::download::{
};
use crate::ephemeral::{Timer as EphemeralTimer, start_chat_ephemeral_timers};
use crate::events::EventType;
use crate::key::{Fingerprint, self_fingerprint};
use crate::key::self_fingerprint;
use crate::location;
use crate::log::{LogExt, warn};
use crate::logged_debug_assert;
@@ -1189,6 +1188,7 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
/// prefer plaintext emails.
///
/// To get more verbose summary for a contact, including its key fingerprint, use [`Contact::get_encrinfo`].
#[expect(clippy::arithmetic_side_effects)]
pub async fn get_encryption_info(self, context: &Context) -> Result<String> {
let chat = Chat::load_from_db(context, self).await?;
if !chat.is_encrypted(context).await? {
@@ -1752,6 +1752,7 @@ impl Chat {
///
/// If `update_msg_id` is set, that record is reused;
/// if `update_msg_id` is None, a new record is created.
#[expect(clippy::arithmetic_side_effects)]
async fn prepare_msg_raw(
&mut self,
context: &Context,
@@ -2947,19 +2948,17 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
"
UPDATE msgs SET
timestamp=(
SELECT MAX(timestamp) FROM msgs INDEXED BY msgs_index7 WHERE
SELECT MAX(timestamp) FROM msgs WHERE
-- From `InFresh` to `OutMdnRcvd` inclusive except `OutDraft`.
state IN(10,13,16,18,20,24,26,28) AND
hidden IN(0,1) AND
chat_id=? AND
id<=?
chat_id=?
),
pre_rfc724_mid=?, subject=?, param=?
WHERE id=?
",
(
msg.chat_id,
msg.id,
&msg.pre_rfc724_mid,
&msg.subject,
msg.param.to_string(),
@@ -3024,6 +3023,7 @@ pub async fn send_text_msg(
}
/// Sends chat members a request to edit the given message's text.
#[expect(clippy::arithmetic_side_effects)]
pub async fn send_edit_request(context: &Context, msg_id: MsgId, new_text: String) -> Result<()> {
let mut original_msg = Message::load_from_db(context, msg_id).await?;
ensure!(
@@ -3128,8 +3128,7 @@ pub async fn get_chat_msgs(context: &Context, chat_id: ChatId) -> Result<Vec<Cha
.await
}
/// Returns messages belonging to the chat according to the given options,
/// sorted by oldest message first.
/// Returns messages belonging to the chat according to the given options.
#[expect(clippy::arithmetic_side_effects)]
pub async fn get_chat_msgs_ex(
context: &Context,
@@ -3140,7 +3139,6 @@ pub async fn get_chat_msgs_ex(
info_only,
add_daymarker,
} = options;
// TODO: Remove `info_only` parameter; it's not used by anything
let process_row = if info_only {
|row: &rusqlite::Row| {
// is_info logic taken from Message.is_info()
@@ -4014,47 +4012,9 @@ pub(crate) async fn add_contact_to_chat_ex(
if sync.into() {
chat.sync_contacts(context).await.log_err(context).ok();
}
if chat.typ == Chattype::OutBroadcast {
resend_last_msgs(context, chat.id, &contact)
.await
.log_err(context)
.ok();
}
Ok(true)
}
async fn resend_last_msgs(context: &Context, chat_id: ChatId, to_contact: &Contact) -> Result<()> {
let msgs: Vec<MsgId> = context
.sql
.query_map_vec(
"
SELECT id
FROM msgs
WHERE chat_id=?
AND hidden=0
AND NOT ( -- Exclude info and system messages
param GLOB '*\nS=*' OR param GLOB 'S=*'
OR from_id=?
OR to_id=?
)
AND type!=?
ORDER BY timestamp DESC, id DESC LIMIT ?",
(
chat_id,
ContactId::INFO,
ContactId::INFO,
Viewtype::Webxdc,
constants::N_MSGS_TO_NEW_BROADCAST_MEMBER,
),
|row: &rusqlite::Row| Ok(row.get::<_, MsgId>(0)?),
)
.await?
.into_iter()
.rev()
.collect();
resend_msgs_ex(context, &msgs, to_contact.fingerprint()).await
}
/// Returns true if an avatar should be attached in the given chat.
///
/// This function does not check if the avatar is set.
@@ -4652,6 +4612,7 @@ pub async fn save_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
/// the copy contains a reference to the original message
/// as well as to the original chat in case the original message gets deleted.
/// Returns data needed to add a `SaveMessage` sync item.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn save_copy_in_self_talk(
context: &Context,
src_msg_id: MsgId,
@@ -4718,26 +4679,10 @@ pub(crate) async fn save_copy_in_self_talk(
Ok(msg.rfc724_mid)
}
/// Resends given messages to members of the corresponding chats.
/// Resends given messages with the same Message-ID.
///
/// This is primarily intended to make existing webxdcs available to new chat members.
pub async fn resend_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
resend_msgs_ex(context, msg_ids, None).await
}
/// Resends given messages to a contact with fingerprint `to_fingerprint` or, if it's `None`, to
/// members of the corresponding chats.
///
/// NB: Actually `to_fingerprint` is only passed for `OutBroadcast` chats when a new member is
/// added. Regarding webxdcs: It is not trivial to resend only the own status updates,
/// and it is not trivial to resend them only to the newly-joined member,
/// so that for now, [`resend_last_msgs`] does not automatically resend webxdcs at all.
pub(crate) async fn resend_msgs_ex(
context: &Context,
msg_ids: &[MsgId],
to_fingerprint: Option<Fingerprint>,
) -> Result<()> {
let to_fingerprint = to_fingerprint.map(|f| f.hex());
let mut msgs: Vec<Message> = Vec::new();
for msg_id in msg_ids {
let msg = Message::load_from_db(context, *msg_id).await?;
@@ -4756,17 +4701,10 @@ pub(crate) async fn resend_msgs_ex(
| MessageState::OutFailed
| MessageState::OutDelivered
| MessageState::OutMdnRcvd => {
// Broadcast owners shouldn't see spinners on messages being auto-re-sent to new
// subscribers (otherwise big channel owners will see spinners most of the time).
if to_fingerprint.is_none() {
message::update_msg_state(context, msg.id, MessageState::OutPending).await?;
}
message::update_msg_state(context, msg.id, MessageState::OutPending).await?
}
msg_state => bail!("Unexpected message state {msg_state}"),
}
if let Some(to_fingerprint) = &to_fingerprint {
msg.param.set(Param::Arg4, to_fingerprint.clone());
}
if create_send_msg_jobs(context, &mut msg).await?.is_empty() {
continue;
}
@@ -4778,8 +4716,7 @@ pub(crate) async fn resend_msgs_ex(
chat_id: msg.chat_id,
msg_id: msg.id,
});
// The event only matters if the message is last in the chat.
// But it's probably too expensive check, and UIs anyways need to debounce.
// note(treefit): only matters if it is the last message in chat (but probably to expensive to check, debounce also solves it)
chatlist_events::emit_chatlist_item_changed(context, msg.chat_id);
if msg.viewtype == Viewtype::Webxdc {

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use super::*;
use crate::Event;
use crate::chatlist::get_archived_cnt;
use crate::constants::{DC_GCL_ARCHIVED_ONLY, DC_GCL_NO_SPECIALS, N_MSGS_TO_NEW_BROADCAST_MEMBER};
use crate::constants::{DC_GCL_ARCHIVED_ONLY, DC_GCL_NO_SPECIALS};
use crate::ephemeral::Timer;
use crate::headerdef::HeaderDef;
use crate::imex::{ImexMode, has_backup, imex};
@@ -2692,49 +2692,6 @@ async fn test_resend_own_message() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_resend_doesnt_resort_msg() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_grp = create_group(alice, "").await?;
let sent1 = alice.send_text(alice_grp, "hi").await;
let sent1_ts = Message::load_from_db(alice, sent1.sender_msg_id)
.await?
.timestamp_sort;
SystemTime::shift(Duration::from_secs(60));
add_contact_to_chat(alice, alice_grp, alice.add_or_lookup_contact_id(bob).await).await?;
let sent2 = alice
.send_text(
alice_grp,
"Let's test resending, there are very few tests on it",
)
.await;
let resent_msg_id = sent1.sender_msg_id;
resend_msgs(alice, &[resent_msg_id]).await?;
assert_eq!(
resent_msg_id.get_state(alice).await?,
MessageState::OutPending
);
alice.pop_sent_msg().await;
assert_eq!(
resent_msg_id.get_state(alice).await?,
MessageState::OutDelivered
);
assert_eq!(
Message::load_from_db(alice, sent1.sender_msg_id)
.await?
.timestamp_sort,
sent1_ts
);
assert_eq!(
alice.get_last_msg_id_in(alice_grp).await,
sent2.sender_msg_id
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_resend_foreign_message_fails() -> Result<()> {
let mut tcm = TestContextManager::new();
@@ -2848,15 +2805,6 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
"alice@example.org charlie@example.net"
);
// Check additionally that subscribers don't send "Chat-Group-Name*" headers.
let parsed = alice.parse_msg(&request_with_auth).await;
assert!(parsed.get_header(HeaderDef::ChatGroupName).is_none());
assert!(
parsed
.get_header(HeaderDef::ChatGroupNameTimestamp)
.is_none()
);
alice.recv_msg_trash(&request_with_auth).await;
}
@@ -2999,56 +2947,6 @@ async fn test_broadcast_change_name() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_resend_to_new_member() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let fiona = &tcm.fiona().await;
let alice_bc_id = create_broadcast(alice, "Channel".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(alice_bc_id)).await.unwrap();
tcm.exec_securejoin_qr(bob, alice, &qr).await;
let mut alice_msg_ids = Vec::new();
for i in 0..(N_MSGS_TO_NEW_BROADCAST_MEMBER + 1) {
alice_msg_ids.push(
alice
.send_text(alice_bc_id, &i.to_string())
.await
.sender_msg_id,
);
}
let fiona_bc_id = tcm.exec_securejoin_qr(fiona, alice, &qr).await;
for msg_id in alice_msg_ids {
assert_eq!(msg_id.get_state(alice).await?, MessageState::OutDelivered);
}
for i in 0..N_MSGS_TO_NEW_BROADCAST_MEMBER {
let rev_order = false;
let resent_msg = alice
.pop_sent_msg_ex(rev_order, Duration::ZERO)
.await
.unwrap();
let fiona_msg = fiona.recv_msg(&resent_msg).await;
assert_eq!(fiona_msg.chat_id, fiona_bc_id);
assert_eq!(fiona_msg.text, (i + 1).to_string());
assert!(resent_msg.recipients.contains("fiona@example.net"));
assert!(!resent_msg.recipients.contains("bob@"));
// The message is undecryptable for Bob, he mustn't be able to know yet that somebody joined
// the broadcast even if he is a postman in this land. E.g. Fiona may leave after fetching
// the news, Bob won't know about that.
assert!(
MimeMessage::from_bytes(bob, resent_msg.payload().as_bytes())
.await?
.decryption_error
.is_some()
);
bob.recv_msg_trash(&resent_msg).await;
}
assert!(alice.pop_sent_msg_opt(Duration::ZERO).await.is_none());
Ok(())
}
/// - Alice has multiple devices
/// - Alice creates a broadcast and sends a message into it
/// - Alice's second device sees the broadcast

View File

@@ -42,85 +42,50 @@ 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.
Addr,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// IMAP server hostname.
MailServer,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// IMAP server username.
MailUser,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// IMAP server password.
MailPw,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// IMAP server port.
MailPort,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// IMAP server security (e.g. TLS, STARTTLS).
MailSecurity,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// How to check TLS certificates.
///
/// "IMAP" in the name is for compatibility,
/// this actually applies to both IMAP and SMTP connections.
ImapCertificateChecks,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// SMTP server hostname.
SendServer,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// SMTP server username.
SendUser,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// SMTP server password.
SendPw,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// SMTP server port.
SendPort,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// SMTP server security (e.g. TLS, STARTTLS).
SendSecurity,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
/// Deprecated option for backwards compatibility.
///
/// Certificate checks for SMTP are actually controlled by `imap_certificate_checks` config.
SmtpCertificateChecks,
/// Whether to use OAuth 2.
///
/// Historically contained other bitflags, which are now deprecated.
@@ -190,6 +155,18 @@ pub enum Config {
#[strum(props(default = "1"))]
MdnsEnabled,
/// True if chat messages should be moved to a separate folder. Auto-sent messages like sync
/// ones are moved there anyway.
#[strum(props(default = "1"))]
MvboxMove,
/// Watch for new messages in the "Mvbox" (aka DeltaChat folder) only.
///
/// This will not entirely disable other folders, e.g. the spam folder will also still
/// be watched for new messages.
#[strum(props(default = "0"))]
OnlyFetchMvbox,
/// Whether to show classic emails or only chat messages.
#[strum(props(default = "2"))] // also change ShowEmails.default() on changes
ShowEmails,
@@ -220,47 +197,32 @@ pub enum Config {
/// The primary email address.
ConfiguredAddr,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// List of configured IMAP servers as a JSON array.
ConfiguredImapServers,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured IMAP server hostname.
///
/// This is replaced by `configured_imap_servers` for new configurations.
ConfiguredMailServer,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured IMAP server port.
///
/// This is replaced by `configured_imap_servers` for new configurations.
ConfiguredMailPort,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured IMAP server security (e.g. TLS, STARTTLS).
///
/// This is replaced by `configured_imap_servers` for new configurations.
ConfiguredMailSecurity,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured IMAP server username.
///
/// This is set if user has configured username manually.
ConfiguredMailUser,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured IMAP server password.
ConfiguredMailPw,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured TLS certificate checks.
/// This option is saved on successful configuration
/// and should not be modified manually.
@@ -269,68 +231,52 @@ pub enum Config {
/// but has "IMAP" in the name for backwards compatibility.
ConfiguredImapCertificateChecks,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// List of configured SMTP servers as a JSON array.
ConfiguredSmtpServers,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured SMTP server hostname.
///
/// This is replaced by `configured_smtp_servers` for new configurations.
ConfiguredSendServer,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured SMTP server port.
///
/// This is replaced by `configured_smtp_servers` for new configurations.
ConfiguredSendPort,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured SMTP server security (e.g. TLS, STARTTLS).
///
/// This is replaced by `configured_smtp_servers` for new configurations.
ConfiguredSendSecurity,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured SMTP server username.
///
/// This is set if user has configured username manually.
ConfiguredSendUser,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured SMTP server password.
ConfiguredSendPw,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
/// Deprecated, stored for backwards compatibility.
///
/// ConfiguredImapCertificateChecks is actually used.
ConfiguredSmtpCertificateChecks,
/// Whether OAuth 2 is used with configured provider.
ConfiguredServerFlags,
/// Configured folder for incoming messages.
ConfiguredInboxFolder,
/// Configured folder for chat messages.
ConfiguredMvboxFolder,
/// Unix timestamp of the last successful configuration.
ConfiguredTimestamp,
/// ID of the configured provider from the provider database.
ConfiguredProvider,
/// Deprecated(2026-04).
/// Use [`Context::is_configured()`] instead.
///
/// True if account is configured.
Configured,
@@ -521,6 +467,7 @@ impl Config {
self,
Self::Displayname
| Self::MdnsEnabled
| Self::MvboxMove
| Self::ShowEmails
| Self::Selfavatar
| Self::Selfstatus,
@@ -529,7 +476,10 @@ impl Config {
/// Whether the config option needs an IO scheduler restart to take effect.
pub(crate) fn needs_io_restart(&self) -> bool {
matches!(self, Config::ConfiguredAddr)
matches!(
self,
Config::MvboxMove | Config::OnlyFetchMvbox | Config::ConfiguredAddr
)
}
}
@@ -644,6 +594,13 @@ impl Context {
.is_some_and(|x| x != 0))
}
/// Returns true if movebox ("DeltaChat" folder) should be watched.
pub(crate) async fn should_watch_mvbox(&self) -> Result<bool> {
Ok(self.get_config_bool(Config::MvboxMove).await?
|| self.get_config_bool(Config::OnlyFetchMvbox).await?
|| !self.get_config_bool(Config::IsChatmail).await?)
}
/// Returns true if sync messages should be sent.
pub(crate) async fn should_send_sync_msgs(&self) -> Result<bool> {
Ok(self.get_config_bool(Config::SyncMsgs).await?
@@ -725,6 +682,8 @@ impl Context {
| Config::ProxyEnabled
| Config::BccSelf
| Config::MdnsEnabled
| Config::MvboxMove
| Config::OnlyFetchMvbox
| Config::Configured
| Config::Bot
| Config::NotifyAboutWrongPw
@@ -747,6 +706,11 @@ impl Context {
pub async fn set_config(&self, key: Config, value: Option<&str>) -> Result<()> {
Self::check_config(key, value)?;
let n_transports = self.count_transports().await?;
if n_transports > 1 && matches!(key, Config::MvboxMove | Config::OnlyFetchMvbox) {
bail!("Cannot reconfigure {key} when multiple transports are configured");
}
let _pause = match key.needs_io_restart() {
true => self.scheduler.pause(self).await?,
_ => Default::default(),
@@ -825,6 +789,12 @@ impl Context {
.set_raw_config(key.as_ref(), value.map(|s| s.to_lowercase()).as_deref())
.await?;
}
Config::MvboxMove => {
self.sql.set_raw_config(key.as_ref(), value).await?;
self.sql
.set_raw_config(constants::DC_FOLDERS_CONFIGURED_KEY, None)
.await?;
}
Config::ConfiguredAddr => {
let Some(addr) = value else {
bail!("Cannot unset configured_addr");

View File

@@ -196,11 +196,11 @@ async fn test_sync() -> Result<()> {
sync(&alice0, &alice1).await;
assert_eq!(alice1.get_config_bool(Config::MdnsEnabled).await?, false);
{
let val = alice0.get_config_bool(Config::ShowEmails).await?;
alice0.set_config_bool(Config::ShowEmails, !val).await?;
for key in [Config::ShowEmails, Config::MvboxMove] {
let val = alice0.get_config_bool(key).await?;
alice0.set_config_bool(key, !val).await?;
sync(&alice0, &alice1).await;
assert_eq!(alice1.get_config_bool(Config::ShowEmails).await?, !val);
assert_eq!(alice1.get_config_bool(key).await?, !val);
}
// `Config::SyncMsgs` mustn't be synced.

View File

@@ -76,7 +76,7 @@ impl Context {
/// Deprecated since 2025-02; use `add_transport_from_qr()`
/// or `add_or_update_transport()` instead.
pub async fn configure(&self) -> Result<()> {
let mut param = EnteredLoginParam::load_legacy(self).await?;
let mut param = EnteredLoginParam::load(self).await?;
self.add_transport_inner(&mut param).await
}
@@ -150,7 +150,7 @@ impl Context {
progress!(self, 0, Some(error_msg.clone()));
bail!(error_msg);
} else {
param.save_legacy(self).await?;
param.save(self).await?;
progress!(self, 1000);
}
@@ -316,16 +316,31 @@ impl Context {
(&param.addr,),
)
.await?
&& self
{
// Should be checked before `MvboxMove` because the latter makes no sense in presense of
// `OnlyFetchMvbox` and even grayed out in the UIs in this case.
if self.get_config(Config::OnlyFetchMvbox).await?.as_deref() != Some("0") {
bail!(
"To use additional relays, disable the legacy option \"Settings / Advanced / Only Fetch from DeltaChat Folder\"."
);
}
if self.get_config(Config::MvboxMove).await?.as_deref() != Some("0") {
bail!(
"To use additional relays, disable the legacy option \"Settings / Advanced / Move automatically to DeltaChat Folder\"."
);
}
if self
.sql
.count("SELECT COUNT(*) FROM transports", ())
.await?
>= MAX_TRANSPORT_RELAYS
{
bail!(
"You have reached the maximum number of relays ({}).",
MAX_TRANSPORT_RELAYS
)
{
bail!(
"You have reached the maximum number of relays ({}).",
MAX_TRANSPORT_RELAYS
)
}
}
let provider = match configure(self, param).await {
@@ -538,7 +553,6 @@ async fn get_configured_param(
.collect(),
imap_user: param.imap.user.clone(),
imap_password: param.imap.password.clone(),
imap_folder: Some(param.imap.folder.clone()).filter(|folder| !folder.is_empty()),
smtp: servers
.iter()
.filter_map(|params| {
@@ -631,6 +645,10 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
progress!(ctx, 900);
let is_configured = ctx.is_configured().await?;
if !is_configured {
ctx.sql.set_raw_config("mvbox_move", Some("0")).await?;
ctx.sql.set_raw_config("only_fetch_mvbox", None).await?;
}
if !ctx.get_config_bool(Config::FixIsChatmail).await? {
if imap_session.is_chatmail() {
ctx.sql.set_raw_config("is_chatmail", Some("1")).await?;
@@ -794,7 +812,7 @@ pub enum Error {
mod tests {
use super::*;
use crate::config::Config;
use crate::login_param::EnteredImapLoginParam;
use crate::login_param::EnteredServerLoginParam;
use crate::test_utils::TestContext;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -813,7 +831,7 @@ mod tests {
let entered_param = EnteredLoginParam {
addr: "alice@example.org".to_string(),
imap: EnteredImapLoginParam {
imap: EnteredServerLoginParam {
user: "alice@example.net".to_string(),
password: "foobar".to_string(),
..Default::default()

View File

@@ -210,6 +210,11 @@ pub const WORSE_IMAGE_SIZE: u32 = 640;
/// usage by UIs.
pub const MAX_RCVD_IMAGE_PIXELS: u32 = 50_000_000;
// Key for the folder configuration version (see below).
pub(crate) const DC_FOLDERS_CONFIGURED_KEY: &str = "folders_configured";
// this value can be increased if the folder configuration is changed and must be redone on next program start
pub(crate) const DC_FOLDERS_CONFIGURED_VERSION: i32 = 5;
// If more recipients are needed in SMTP's `RCPT TO:` header, the recipient list is split into
// chunks. This does not affect MIME's `To:` header. Can be overwritten by setting
// `max_smtp_rcpt_to` in the provider db.
@@ -244,9 +249,6 @@ Here is what to do:
If you have any questions, please send an email to delta@merlinux.eu or ask at https://support.delta.chat/."#;
/// How many recent messages should be re-sent to a new broadcast member.
pub(crate) const N_MSGS_TO_NEW_BROADCAST_MEMBER: usize = 10;
#[cfg(test)]
mod tests {
use num_traits::FromPrimitive;

View File

@@ -2068,6 +2068,7 @@ pub(crate) async fn mark_contact_id_as_verified(
Ok(())
}
#[expect(clippy::arithmetic_side_effects)]
fn cat_fingerprint(ret: &mut String, name: &str, addr: &str, fingerprint: &str) {
*ret += &format!("\n\n{name} ({addr}):\n{fingerprint}");
}

View File

@@ -4,7 +4,6 @@ use super::*;
use crate::chat::{Chat, get_chat_contacts, send_text_msg};
use crate::chatlist::Chatlist;
use crate::receive_imf::receive_imf;
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::{self, TestContext, TestContextManager, TimeShiftFalsePositiveNote, sync};
#[test]
@@ -155,50 +154,6 @@ async fn test_get_contacts() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_search_contacts_from_group() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let fiona = &tcm.fiona().await;
let alice_chat_id = chat::create_group(alice, "").await?;
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await;
tcm.exec_securejoin_qr(fiona, alice, &qr).await;
// Workaround for "member added" for fiona not sent to bob.
let gossip_period = alice.get_config_int(Config::GossipPeriod).await?;
SystemTime::shift(Duration::from_secs(gossip_period.try_into()?));
send_text_msg(alice, alice_chat_id, "hello".to_string()).await?;
let sent_msg = alice.pop_sent_msg().await;
bob.recv_msg(&sent_msg).await;
fiona.recv_msg(&sent_msg).await;
let contacts = Contact::get_all(bob, 0, None).await?;
let bob_alice_id = bob.add_or_lookup_contact_id(alice).await;
assert_eq!(contacts.len(), 1);
assert_eq!(contacts[0], bob_alice_id);
let contacts = Contact::get_all(fiona, 0, None).await?;
let fiona_alice_id = fiona.add_or_lookup_contact_id(alice).await;
assert_eq!(contacts.len(), 1);
assert_eq!(contacts[0], fiona_alice_id);
// Sending to the group adds new members to the contact list.
send_text_msg(bob, bob_chat_id, "hello".to_string()).await?;
fiona.recv_msg(&bob.pop_sent_msg().await).await;
let contacts = Contact::get_all(bob, 0, None).await?;
let bob_fiona_id = bob.add_or_lookup_contact_id(fiona).await;
assert_eq!(contacts.len(), 2);
assert_eq!(contacts[0], bob_alice_id);
assert_eq!(contacts[1], bob_fiona_id);
let contacts = Contact::get_all(fiona, 0, None).await?;
assert_eq!(contacts.len(), 1);
assert_eq!(contacts[0], fiona_alice_id);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_is_self_addr() -> Result<()> {
let t = TestContext::new().await;

View File

@@ -20,16 +20,16 @@ use crate::constants::{self, DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT, DC_VERSI
use crate::contact::{Contact, ContactId};
use crate::debug_logging::DebugLogging;
use crate::events::{Event, EventEmitter, EventType, Events};
use crate::imap::{Imap, ServerMetadata};
use crate::imap::{FolderMeaning, Imap, ServerMetadata};
use crate::key::self_fingerprint;
use crate::log::warn;
use crate::logged_debug_assert;
use crate::message::{self, MessageState, MsgId};
use crate::net::tls::{SpkiHashStore, TlsSessionStore};
use crate::net::tls::TlsSessionStore;
use crate::peer_channels::Iroh;
use crate::push::PushSubscriber;
use crate::quota::QuotaInfo;
use crate::scheduler::{ConnectivityStore, SchedulerState};
use crate::scheduler::{ConnectivityStore, SchedulerState, convert_folder_meaning};
use crate::sql::Sql;
use crate::stock_str::StockStrings;
use crate::timesmearing::SmearedTimestamp;
@@ -308,13 +308,6 @@ pub struct InnerContext {
/// TLS session resumption cache.
pub(crate) tls_session_store: TlsSessionStore,
/// Store for TLS SPKI hashes.
///
/// Used to remember public keys
/// of TLS certificates to accept them
/// even after they expire.
pub(crate) spki_hash_store: SpkiHashStore,
/// Iroh for realtime peer channels.
pub(crate) iroh: Arc<RwLock<Option<Iroh>>>,
@@ -518,7 +511,6 @@ impl Context {
push_subscriber,
push_subscribed: AtomicBool::new(false),
tls_session_store: TlsSessionStore::new(),
spki_hash_store: SpkiHashStore::new(),
iroh: Arc::new(RwLock::new(None)),
self_fingerprint: OnceLock::new(),
self_public_key: Mutex::new(None),
@@ -568,15 +560,6 @@ impl Context {
}
}
/// Requests deletion of all messages from chatmail relays.
///
/// Non-chatmail relays are excluded
/// to avoid accidentally deleting emails
/// from shared inboxes.
pub async fn clear_all_relay_storage(&self) -> Result<()> {
self.scheduler.clear_all_relay_storage().await
}
/// Restarts the IO scheduler if it was running before
/// when it is not running this is an no-op
pub async fn restart_io_if_running(&self) {
@@ -640,10 +623,17 @@ impl Context {
let mut session = connection.prepare(self).await?;
// Fetch IMAP folders.
let folder = connection.folder.clone();
connection
.fetch_move_delete(self, &mut session, &folder)
.await?;
// Inbox is fetched before Mvbox because fetching from Inbox
// may result in moving some messages to Mvbox.
for folder_meaning in [FolderMeaning::Inbox, FolderMeaning::Mvbox] {
if let Some((_folder_config, watch_folder)) =
convert_folder_meaning(self, folder_meaning).await?
{
connection
.fetch_move_delete(self, &mut session, &watch_folder, folder_meaning)
.await?;
}
}
// Update quota (to send warning if full) - but only check it once in a while.
// note: For now this only checks quota of primary transport,
@@ -654,7 +644,7 @@ impl Context {
DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT,
)
.await
&& let Err(err) = self.update_recent_quota(&mut session, &folder).await
&& let Err(err) = self.update_recent_quota(&mut session).await
{
warn!(self, "Failed to update quota: {err:#}.");
}
@@ -894,6 +884,23 @@ impl Context {
Err(err) => format!("<key failure: {err}>"),
};
let mvbox_move = self.get_config_int(Config::MvboxMove).await?;
let only_fetch_mvbox = self.get_config_int(Config::OnlyFetchMvbox).await?;
let folders_configured = self
.sql
.get_raw_config_int(constants::DC_FOLDERS_CONFIGURED_KEY)
.await?
.unwrap_or_default();
let configured_inbox_folder = self
.get_config(Config::ConfiguredInboxFolder)
.await?
.unwrap_or_else(|| "<unset>".to_string());
let configured_mvbox_folder = self
.get_config(Config::ConfiguredMvboxFolder)
.await?
.unwrap_or_else(|| "<unset>".to_string());
let mut res = get_info();
// insert values
@@ -969,6 +976,14 @@ impl Context {
.await?
.to_string(),
);
res.insert("mvbox_move", mvbox_move.to_string());
res.insert("only_fetch_mvbox", only_fetch_mvbox.to_string());
res.insert(
constants::DC_FOLDERS_CONFIGURED_KEY,
folders_configured.to_string(),
);
res.insert("configured_inbox_folder", configured_inbox_folder);
res.insert("configured_mvbox_folder", configured_mvbox_folder);
res.insert("mdns_enabled", mdns_enabled.to_string());
res.insert("bcc_self", bcc_self.to_string());
res.insert("sync_msgs", sync_msgs.to_string());
@@ -1282,6 +1297,12 @@ ORDER BY m.timestamp DESC,m.id DESC",
Ok(list)
}
/// Returns true if given folder name is the name of the "DeltaChat" folder.
pub async fn is_mvbox(&self, folder_name: &str) -> Result<bool> {
let mvbox = self.get_config(Config::ConfiguredMvboxFolder).await?;
Ok(mvbox.as_deref() == Some(folder_name))
}
pub(crate) fn derive_blobdir(dbfile: &Path) -> PathBuf {
let mut blob_fname = OsString::new();
blob_fname.push(dbfile.file_name().unwrap_or_default());

View File

@@ -284,6 +284,7 @@ async fn test_get_info_completeness() {
"send_security",
"server_flags",
"skip_start_messages",
"smtp_certificate_checks",
"proxy_url", // May contain passwords, don't leak it to the logs.
"socks5_enabled", // SOCKS5 options are deprecated.
"socks5_host",

View File

@@ -202,11 +202,8 @@ impl Session {
let mut uid_message_ids: BTreeMap<u32, String> = BTreeMap::new();
uid_message_ids.insert(uid, rfc724_mid);
let (sender, receiver) = async_channel::unbounded();
{
let _fetch_msgs_lock_guard = context.fetch_msgs_mutex.lock().await;
self.fetch_many_msgs(context, folder, vec![uid], &uid_message_ids, sender)
.await?;
}
self.fetch_many_msgs(context, folder, vec![uid], &uid_message_ids, sender)
.await?;
if receiver.recv().await.is_err() {
bail!("Failed to fetch UID {uid}");
}

View File

@@ -89,9 +89,13 @@ mod test_chatlist_events {
.get_matching(|evt| match evt {
EventType::ChatlistItemChanged {
chat_id: Some(ev_chat_id),
} if ev_chat_id == &chat_id => {
first_event_is_item.store(true, Ordering::Relaxed);
true
} => {
if ev_chat_id == &chat_id {
first_event_is_item.store(true, Ordering::Relaxed);
true
} else {
false
}
}
EventType::ChatlistChanged => true,
_ => false,

View File

@@ -86,6 +86,7 @@ impl HtmlMsgParser {
/// Function takes a raw mime-message string,
/// searches for the main-text part
/// and returns that as parser.html
#[expect(clippy::arithmetic_side_effects)]
pub fn from_bytes<'a>(
context: &Context,
rawmime: &'a [u8],
@@ -119,6 +120,7 @@ impl HtmlMsgParser {
/// Usually, there is at most one plain-text and one HTML-text part,
/// multiple plain-text parts might be used for mailinglist-footers,
/// therefore we use the first one.
#[expect(clippy::arithmetic_side_effects)]
fn collect_texts_recursive<'a>(
&'a mut self,
context: &'a Context,

View File

@@ -27,14 +27,13 @@ use crate::calls::{
use crate::chat::{self, ChatId, ChatIdBlocked, add_device_msg};
use crate::chatlist_events;
use crate::config::Config;
use crate::constants::{Blocked, DC_VERSION_STR};
use crate::constants::{self, Blocked, DC_VERSION_STR};
use crate::contact::ContactId;
use crate::context::Context;
use crate::ensure_and_debug_assert;
use crate::events::EventType;
use crate::headerdef::{HeaderDef, HeaderDefMap};
use crate::log::{LogExt, warn};
use crate::message::{self, Message, MessageState, MsgId};
use crate::message::{self, Message, MessageState, MessengerMessage, MsgId};
use crate::mimeparser;
use crate::net::proxy::ProxyConfig;
use crate::net::session::SessionStream;
@@ -92,9 +91,6 @@ pub(crate) struct Imap {
oauth2: bool,
/// Watched folder.
pub(crate) folder: String,
authentication_failed_once: bool,
pub(crate) connectivity: ConnectivityStore,
@@ -166,6 +162,7 @@ pub enum FolderMeaning {
/// Spam folder.
Spam,
Inbox,
Mvbox,
Trash,
/// Virtual folders.
@@ -177,6 +174,19 @@ pub enum FolderMeaning {
Virtual,
}
impl FolderMeaning {
pub fn to_config(self) -> Option<Config> {
match self {
FolderMeaning::Unknown => None,
FolderMeaning::Spam => None,
FolderMeaning::Inbox => Some(Config::ConfiguredInboxFolder),
FolderMeaning::Mvbox => Some(Config::ConfiguredMvboxFolder),
FolderMeaning::Trash => None,
FolderMeaning::Virtual => None,
}
}
}
struct UidGrouper<T: Iterator<Item = (i64, u32, String)>> {
inner: Peekable<T>,
}
@@ -253,11 +263,6 @@ impl Imap {
let addr = &param.addr;
let strict_tls = param.strict_tls(proxy_config.is_some());
let oauth2 = param.oauth2;
let folder = param
.imap_folder
.clone()
.unwrap_or_else(|| "INBOX".to_string());
ensure_and_debug_assert!(!folder.is_empty(), "Watched folder name cannot be empty");
let (resync_request_sender, resync_request_receiver) = async_channel::bounded(1);
Ok(Imap {
transport_id,
@@ -268,7 +273,6 @@ impl Imap {
proxy_config,
strict_tls,
oauth2,
folder,
authentication_failed_once: false,
connectivity: Default::default(),
conn_last_try: UNIX_EPOCH,
@@ -486,14 +490,22 @@ impl Imap {
/// that folders are created and IMAP capabilities are determined.
pub(crate) async fn prepare(&mut self, context: &Context) -> Result<Session> {
let configuring = false;
let session = match self.connect(context, configuring).await {
let mut session = match self.connect(context, configuring).await {
Ok(session) => session,
Err(err) => {
self.connectivity.set_err(context, format!("{err:#}"));
self.connectivity.set_err(context, &err);
return Err(err);
}
};
let folders_configured = context
.sql
.get_raw_config_int(constants::DC_FOLDERS_CONFIGURED_KEY)
.await?;
if folders_configured.unwrap_or_default() < constants::DC_FOLDERS_CONFIGURED_VERSION {
self.configure_folders(context, &mut session).await?;
}
Ok(session)
}
@@ -506,15 +518,15 @@ impl Imap {
context: &Context,
session: &mut Session,
watch_folder: &str,
folder_meaning: FolderMeaning,
) -> Result<()> {
ensure_and_debug_assert!(!watch_folder.is_empty(), "Watched folder cannot be empty");
if !context.sql.is_open().await {
// probably shutdown
bail!("IMAP operation attempted while it is torn down");
}
let msgs_fetched = self
.fetch_new_messages(context, session, watch_folder)
.fetch_new_messages(context, session, watch_folder, folder_meaning)
.await
.context("fetch_new_messages")?;
if msgs_fetched && context.get_config_delete_device_after().await?.is_some() {
@@ -542,9 +554,19 @@ impl Imap {
context: &Context,
session: &mut Session,
folder: &str,
folder_meaning: FolderMeaning,
) -> Result<bool> {
let transport_id = session.transport_id();
if should_ignore_folder(context, folder, folder_meaning).await? {
info!(
context,
"Transport {transport_id}: Not fetching from {folder:?}."
);
session.new_mail = false;
return Ok(false);
}
let folder_exists = session
.select_with_uidvalidity(context, folder)
.await
@@ -568,7 +590,8 @@ impl Imap {
let mut read_cnt = 0;
loop {
let (n, fetch_more) =
Box::pin(self.fetch_new_msg_batch(context, session, folder)).await?;
Box::pin(self.fetch_new_msg_batch(context, session, folder, folder_meaning))
.await?;
read_cnt += n;
if !fetch_more {
return Ok(read_cnt > 0);
@@ -583,6 +606,7 @@ impl Imap {
context: &Context,
session: &mut Session,
folder: &str,
folder_meaning: FolderMeaning,
) -> Result<(usize, bool)> {
let transport_id = self.transport_id;
let uid_validity = get_uidvalidity(context, transport_id, folder).await?;
@@ -652,7 +676,13 @@ impl Imap {
info!(context, "Deleting locally deleted message {message_id}.");
}
let target = if delete { "" } else { folder };
let _target;
let target = if delete {
""
} else {
_target = target_folder(context, folder, folder_meaning, &headers).await?;
&_target
};
context
.sql
@@ -680,9 +710,18 @@ impl Imap {
// 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
&& prefetch_should_download(context, &headers, &message_id, fetch_response.flags())
.await
.context("prefetch_should_download")?
// Never download messages directly from the spam folder.
// If the sender is known, the message will be moved to the Inbox or Mvbox
// and then we download the message from there.
// Also see `spam_target_folder_cfg()`.
&& folder_meaning != FolderMeaning::Spam
&& prefetch_should_download(
context,
&headers,
&message_id,
fetch_response.flags(),
)
.await.context("prefetch_should_download")?
{
if headers
.get_header_value(HeaderDef::ChatIsPostMessage)
@@ -945,29 +984,6 @@ impl Session {
Ok(())
}
/// Deletes all messages from IMAP folder.
pub(crate) async fn delete_all_messages(
&mut self,
context: &Context,
folder: &str,
) -> Result<()> {
let transport_id = self.transport_id();
if self.select_with_uidvalidity(context, folder).await? {
self.add_flag_finalized_with_set("1:*", "\\Deleted").await?;
self.selected_folder_needs_expunge = true;
context
.sql
.execute(
"DELETE FROM imap WHERE transport_id=? AND folder=?",
(transport_id, folder),
)
.await?;
}
Ok(())
}
/// Moves batch of messages identified by their UID from the currently
/// selected folder to the target folder.
async fn move_message_batch(
@@ -1655,8 +1671,13 @@ impl Session {
// Store new encrypted device token on the server
// even if it is the same as the old one.
if let Some(encrypted_device_token) = new_encrypted_device_token {
let folder = context
.get_config(Config::ConfiguredInboxFolder)
.await?
.context("INBOX is not configured")?;
self.run_command_and_check_ok(&format_setmetadata(
"INBOX",
&folder,
&encrypted_device_token,
))
.await
@@ -1701,6 +1722,117 @@ impl Session {
}
Ok(())
}
/// Attempts to configure mvbox.
///
/// Tries to find any folder examining `folders` in the order they go.
/// This method does not use LIST command to ensure that
/// configuration works even if mailbox lookup is forbidden via Access Control List (see
/// <https://datatracker.ietf.org/doc/html/rfc4314>).
///
/// Returns first found folder name.
async fn configure_mvbox<'a>(
&mut self,
context: &Context,
folders: &[&'a str],
) -> Result<Option<&'a str>> {
// Close currently selected folder if needed.
// We are going to select folders using low-level EXAMINE operations below.
self.maybe_close_folder(context).await?;
for folder in folders {
info!(context, "Looking for MVBOX-folder \"{}\"...", &folder);
let res = self.examine(&folder).await;
if res.is_ok() {
info!(
context,
"MVBOX-folder {:?} successfully selected, using it.", &folder
);
self.close().await?;
// Before moving emails to the mvbox we need to remember its UIDVALIDITY, otherwise
// emails moved before that wouldn't be fetched but considered "old" instead.
let folder_exists = self.select_with_uidvalidity(context, folder).await?;
ensure!(folder_exists, "No MVBOX folder {:?}??", &folder);
return Ok(Some(folder));
}
}
Ok(None)
}
}
impl Imap {
pub(crate) async fn configure_folders(
&mut self,
context: &Context,
session: &mut Session,
) -> Result<()> {
let mut folders = session
.list(Some(""), Some("*"))
.await
.context("list_folders failed")?;
let mut delimiter = ".".to_string();
let mut delimiter_is_default = true;
let mut folder_configs = BTreeMap::new();
while let Some(folder) = folders.try_next().await? {
info!(context, "Scanning folder: {:?}", folder);
// Update the delimiter iff there is a different one, but only once.
if let Some(d) = folder.delimiter()
&& delimiter_is_default
&& !d.is_empty()
&& delimiter != d
{
delimiter = d.to_string();
delimiter_is_default = false;
}
let folder_meaning = get_folder_meaning_by_attrs(folder.attributes());
let folder_name_meaning = get_folder_meaning_by_name(folder.name());
if let Some(config) = folder_meaning.to_config() {
// Always takes precedence
folder_configs.insert(config, folder.name().to_string());
} else if let Some(config) = folder_name_meaning.to_config() {
// only set if none has been already set
folder_configs
.entry(config)
.or_insert_with(|| folder.name().to_string());
}
}
drop(folders);
info!(context, "Using \"{}\" as folder-delimiter.", delimiter);
let fallback_folder = format!("INBOX{delimiter}DeltaChat");
let mvbox_folder = session
.configure_mvbox(context, &["DeltaChat", &fallback_folder])
.await
.context("failed to configure mvbox")?;
context
.set_config_internal(Config::ConfiguredInboxFolder, Some("INBOX"))
.await?;
if let Some(mvbox_folder) = mvbox_folder {
info!(context, "Setting MVBOX FOLDER TO {}", &mvbox_folder);
context
.set_config_internal(Config::ConfiguredMvboxFolder, Some(mvbox_folder))
.await?;
}
for (config, name) in folder_configs {
context.set_config_internal(config, Some(&name)).await?;
}
context
.sql
.set_raw_config_int(
constants::DC_FOLDERS_CONFIGURED_KEY,
constants::DC_FOLDERS_CONFIGURED_VERSION,
)
.await?;
info!(context, "FINISHED configuring IMAP-folders.");
Ok(())
}
}
impl Session {
@@ -1834,7 +1966,15 @@ async fn spam_target_folder_cfg(
return Ok(None);
}
Ok(Some(Config::ConfiguredInboxFolder))
if needs_move_to_mvbox(context, headers).await?
// If OnlyFetchMvbox is set, we don't want to move the message to
// the inbox where we wouldn't fetch it again:
|| context.get_config_bool(Config::OnlyFetchMvbox).await?
{
Ok(Some(Config::ConfiguredMvboxFolder))
} else {
Ok(Some(Config::ConfiguredInboxFolder))
}
}
/// Returns `ConfiguredInboxFolder` or `ConfiguredMvboxFolder` if
@@ -1845,12 +1985,16 @@ pub async fn target_folder_cfg(
folder_meaning: FolderMeaning,
headers: &[mailparse::MailHeader<'_>],
) -> Result<Option<Config>> {
if folder == "DeltaChat" {
if context.is_mvbox(folder).await? {
return Ok(None);
}
if folder_meaning == FolderMeaning::Spam {
spam_target_folder_cfg(context, headers).await
} else if folder_meaning == FolderMeaning::Inbox
&& needs_move_to_mvbox(context, headers).await?
{
Ok(Some(Config::ConfiguredMvboxFolder))
} else {
Ok(None)
}
@@ -1871,6 +2015,27 @@ pub async fn target_folder(
}
}
async fn needs_move_to_mvbox(
context: &Context,
headers: &[mailparse::MailHeader<'_>],
) -> Result<bool> {
let has_chat_version = headers.get_header_value(HeaderDef::ChatVersion).is_some();
if !context.get_config_bool(Config::MvboxMove).await? {
return Ok(false);
}
if has_chat_version {
Ok(true)
} else if let Some(parent) = get_prefetch_parent_message(context, headers).await? {
match parent.is_dc_message {
MessengerMessage::No => Ok(false),
MessengerMessage::Yes | MessengerMessage::Reply => Ok(true),
}
} else {
Ok(false)
}
}
/// Try to get the folder meaning by the name of the folder only used if the server does not support XLIST.
// TODO: lots languages missing - maybe there is a list somewhere on other MUAs?
// however, if we fail to find out the sent-folder,
@@ -2201,6 +2366,21 @@ async fn get_modseq(context: &Context, transport_id: u32, folder: &str) -> Resul
.unwrap_or(0))
}
/// Whether to ignore fetching messages from a folder.
///
/// This caters for the [`Config::OnlyFetchMvbox`] setting which means mails from folders
/// not explicitly watched should not be fetched.
async fn should_ignore_folder(
context: &Context,
folder: &str,
folder_meaning: FolderMeaning,
) -> Result<bool> {
if !context.get_config_bool(Config::OnlyFetchMvbox).await? {
return Ok(false);
}
Ok(!(context.is_mvbox(folder).await? || folder_meaning == FolderMeaning::Spam))
}
/// Builds a list of sequence/uid sets. The returned sets have each no more than around 1000
/// characters because according to <https://tools.ietf.org/html/rfc2683#section-3.2.1.5>
/// command lines should not be much more than 1000 chars (servers should allow at least 8000 chars)
@@ -2260,5 +2440,23 @@ impl std::fmt::Display for UidRange {
}
}
pub(crate) async fn get_watched_folder_configs(context: &Context) -> Result<Vec<Config>> {
let mut res = vec![Config::ConfiguredInboxFolder];
if context.should_watch_mvbox().await? {
res.push(Config::ConfiguredMvboxFolder);
}
Ok(res)
}
pub(crate) async fn get_watched_folders(context: &Context) -> Result<Vec<String>> {
let mut res = Vec::new();
for folder_config in get_watched_folder_configs(context).await? {
if let Some(folder) = context.get_config(folder_config).await? {
res.push(folder);
}
}
Ok(res)
}
#[cfg(test)]
mod imap_tests;

View File

@@ -220,8 +220,6 @@ impl Client {
alpn(addr.port()),
logging_stream,
&context.tls_session_store,
&context.spki_hash_store,
&context.sql,
)
.await?;
let buffered_stream = BufWriter::new(tls_stream);
@@ -284,8 +282,6 @@ impl Client {
"",
tcp_stream,
&context.tls_session_store,
&context.spki_hash_store,
&context.sql,
)
.await
.context("STARTTLS upgrade failed")?;
@@ -314,8 +310,6 @@ impl Client {
alpn(port),
proxy_stream,
&context.tls_session_store,
&context.spki_hash_store,
&context.sql,
)
.await?;
let buffered_stream = BufWriter::new(tls_stream);
@@ -379,8 +373,6 @@ impl Client {
"",
proxy_stream,
&context.tls_session_store,
&context.spki_hash_store,
&context.sql,
)
.await
.context("STARTTLS upgrade failed")?;

View File

@@ -120,7 +120,11 @@ impl Session {
impl Imap {
/// Idle using polling.
pub(crate) async fn fake_idle(&mut self, context: &Context, watch_folder: &str) -> Result<()> {
pub(crate) async fn fake_idle(
&mut self,
context: &Context,
watch_folder: String,
) -> Result<()> {
let fake_idle_start_time = tools::Time::now();
info!(context, "IMAP-fake-IDLEing folder={:?}", watch_folder);

View File

@@ -100,16 +100,23 @@ fn test_build_sequence_sets() {
async fn check_target_folder_combination(
folder: &str,
mvbox_move: bool,
chat_msg: bool,
expected_destination: &str,
accepted_chat: bool,
outgoing: bool,
) -> Result<()> {
println!(
"Testing: For folder {folder}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}"
"Testing: For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}"
);
let t = TestContext::new_alice().await;
t.ctx
.set_config(Config::ConfiguredMvboxFolder, Some("DeltaChat"))
.await?;
t.ctx
.set_config(Config::MvboxMove, Some(if mvbox_move { "1" } else { "0" }))
.await?;
if accepted_chat {
let contact_id = Contact::create(&t.ctx, "", "bob@example.net").await?;
@@ -154,42 +161,64 @@ async fn check_target_folder_combination(
assert_eq!(
expected,
actual.as_deref(),
"For folder {folder}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}: expected {expected:?}, got {actual:?}"
"For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}: expected {expected:?}, got {actual:?}"
);
Ok(())
}
// chat_msg means that the message was sent by Delta Chat
// The tuples are (folder, chat_msg, expected_destination)
const COMBINATIONS_ACCEPTED_CHAT: &[(&str, bool, &str)] = &[
("INBOX", false, "INBOX"),
("INBOX", true, "INBOX"),
("Spam", false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs
("Spam", true, "INBOX"),
// The tuples are (folder, mvbox_move, chat_msg, expected_destination)
const COMBINATIONS_ACCEPTED_CHAT: &[(&str, bool, bool, &str)] = &[
("INBOX", false, false, "INBOX"),
("INBOX", false, true, "INBOX"),
("INBOX", true, false, "INBOX"),
("INBOX", true, true, "DeltaChat"),
("Spam", false, false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs
("Spam", false, true, "INBOX"),
("Spam", true, false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs
("Spam", true, true, "DeltaChat"),
];
// These are the same as above, but non-chat messages in Spam stay in Spam
const COMBINATIONS_REQUEST: &[(&str, bool, &str)] = &[
("INBOX", false, "INBOX"),
("INBOX", true, "INBOX"),
("Spam", false, "Spam"),
("Spam", true, "INBOX"),
const COMBINATIONS_REQUEST: &[(&str, bool, bool, &str)] = &[
("INBOX", false, false, "INBOX"),
("INBOX", false, true, "INBOX"),
("INBOX", true, false, "INBOX"),
("INBOX", true, true, "DeltaChat"),
("Spam", false, false, "Spam"),
("Spam", false, true, "INBOX"),
("Spam", true, false, "Spam"),
("Spam", true, true, "DeltaChat"),
];
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_incoming_accepted() -> Result<()> {
for (folder, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(folder, *chat_msg, expected_destination, true, false)
.await?;
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(
folder,
*mvbox_move,
*chat_msg,
expected_destination,
true,
false,
)
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_incoming_request() -> Result<()> {
for (folder, chat_msg, expected_destination) in COMBINATIONS_REQUEST {
check_target_folder_combination(folder, *chat_msg, expected_destination, false, false)
.await?;
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_REQUEST {
check_target_folder_combination(
folder,
*mvbox_move,
*chat_msg,
expected_destination,
false,
false,
)
.await?;
}
Ok(())
}
@@ -197,9 +226,16 @@ async fn test_target_folder_incoming_request() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_outgoing() -> Result<()> {
// Test outgoing emails
for (folder, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(folder, *chat_msg, expected_destination, true, true)
.await?;
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(
folder,
*mvbox_move,
*chat_msg,
expected_destination,
true,
true,
)
.await?;
}
Ok(())
}

View File

@@ -16,7 +16,7 @@ use crate::net::session::SessionStream;
/// - Autocrypt-Setup-Message to check if a message is an autocrypt setup message,
/// not necessarily sent by Delta Chat.
/// - Chat-Is-Post-Message to skip it in background fetch or when it is > `DownloadLimit`.
const PREFETCH_FLAGS: &str = "(UID RFC822.SIZE BODY.PEEK[HEADER.FIELDS (\
const PREFETCH_FLAGS: &str = "(UID INTERNALDATE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (\
MESSAGE-ID \
DATE \
X-MICROSOFT-ORIGINAL-MESSAGE-ID \
@@ -124,7 +124,7 @@ impl Session {
}
/// Prefetch `n_uids` messages starting from `uid_next`. Returns a list of fetch results in the
/// order of ascending UIDs.
/// order of ascending delivery time to the server (INTERNALDATE).
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn prefetch(
&mut self,
@@ -142,10 +142,10 @@ impl Session {
let mut msgs = BTreeMap::new();
while let Some(msg) = list.try_next().await? {
if let Some(msg_uid) = msg.uid {
msgs.insert(msg_uid, msg);
msgs.insert((msg.internal_date(), msg_uid), msg);
}
}
Ok(Vec::from_iter(msgs))
Ok(msgs.into_iter().map(|((_, uid), msg)| (uid, msg)).collect())
}
}

View File

@@ -56,37 +56,9 @@ pub enum EnteredCertificateChecks {
AcceptInvalidCertificates2 = 3,
}
/// Login parameters for a single IMAP server.
/// Login parameters for a single server, either IMAP or SMTP
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnteredImapLoginParam {
/// Server hostname or IP address.
pub server: String,
/// Server port.
///
/// 0 if not specified.
pub port: u16,
/// Folder to watch.
///
/// If empty, user has not entered anything and it shuold expand to "INBOX" later.
pub folder: String,
/// Socket security.
pub security: Socket,
/// Username.
///
/// Empty string if not specified.
pub user: String,
/// Password.
pub password: String,
}
/// Login parameters for a single SMTP server.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnteredSmtpLoginParam {
pub struct EnteredServerLoginParam {
/// Server hostname or IP address.
pub server: String,
@@ -124,10 +96,10 @@ pub struct EnteredLoginParam {
pub addr: String,
/// IMAP settings.
pub imap: EnteredImapLoginParam,
pub imap: EnteredServerLoginParam,
/// SMTP settings.
pub smtp: EnteredSmtpLoginParam,
pub smtp: EnteredServerLoginParam,
/// TLS options: whether to allow invalid certificates and/or
/// invalid hostnames
@@ -138,11 +110,8 @@ pub struct EnteredLoginParam {
}
impl EnteredLoginParam {
/// Loads entered account settings
/// that were set by the deprecated `configured_*` configs.
///
/// This is only needed by tests and clients using the old CFFI API.
pub(crate) async fn load_legacy(context: &Context) -> Result<Self> {
/// Loads entered account settings.
pub(crate) async fn load(context: &Context) -> Result<Self> {
let addr = context
.get_config(Config::Addr)
.await?
@@ -158,10 +127,6 @@ impl EnteredLoginParam {
.get_config_parsed::<u16>(Config::MailPort)
.await?
.unwrap_or_default();
// There is no way to set custom folder with this legacy API.
let mail_folder = String::new();
let mail_security = context
.get_config_parsed::<i32>(Config::MailSecurity)
.await?
@@ -179,7 +144,7 @@ impl EnteredLoginParam {
// The setting is named `imap_certificate_checks`
// for backwards compatibility,
// but now it is a global setting applied to all protocols,
// while `smtp_certificate_checks` has been removed.
// while `smtp_certificate_checks` is ignored.
let certificate_checks = if let Some(certificate_checks) = context
.get_config_parsed::<i32>(Config::ImapCertificateChecks)
.await?
@@ -220,15 +185,14 @@ impl EnteredLoginParam {
Ok(EnteredLoginParam {
addr,
imap: EnteredImapLoginParam {
imap: EnteredServerLoginParam {
server: mail_server,
port: mail_port,
folder: mail_folder,
security: mail_security,
user: mail_user,
password: mail_pw,
},
smtp: EnteredSmtpLoginParam {
smtp: EnteredServerLoginParam {
server: send_server,
port: send_port,
security: send_security,
@@ -242,10 +206,7 @@ impl EnteredLoginParam {
/// Saves entered account settings,
/// so that they can be prefilled if the user wants to configure the server again.
///
/// This is needed in case a UI is not yet updated, and still uses `get_config("mail_pw")` etc.
/// in order to prefill the entered account settings.
pub(crate) async fn save_legacy(&self, context: &Context) -> Result<()> {
pub(crate) async fn save(&self, context: &Context) -> Result<()> {
context.set_config(Config::Addr, Some(&self.addr)).await?;
context
@@ -368,7 +329,7 @@ mod tests {
.await?;
t.set_config(Config::MailPw, Some("foobarbaz")).await?;
let param = EnteredLoginParam::load_legacy(t).await?;
let param = EnteredLoginParam::load(t).await?;
assert_eq!(param.addr, "alice@example.org");
assert_eq!(
param.certificate_checks,
@@ -377,13 +338,13 @@ mod tests {
t.set_config(Config::ImapCertificateChecks, Some("1"))
.await?;
let param = EnteredLoginParam::load_legacy(t).await?;
let param = EnteredLoginParam::load(t).await?;
assert_eq!(param.certificate_checks, EnteredCertificateChecks::Strict);
// Fail to load invalid settings, but do not panic.
t.set_config(Config::ImapCertificateChecks, Some("999"))
.await?;
assert!(EnteredLoginParam::load_legacy(t).await.is_err());
assert!(EnteredLoginParam::load(t).await.is_err());
Ok(())
}
@@ -393,15 +354,14 @@ mod tests {
let t = TestContext::new().await;
let param = EnteredLoginParam {
addr: "alice@example.org".to_string(),
imap: EnteredImapLoginParam {
imap: EnteredServerLoginParam {
server: "".to_string(),
port: 0,
folder: "".to_string(),
security: Socket::Starttls,
user: "".to_string(),
password: "foobar".to_string(),
},
smtp: EnteredSmtpLoginParam {
smtp: EnteredServerLoginParam {
server: "".to_string(),
port: 2947,
security: Socket::default(),
@@ -411,7 +371,7 @@ mod tests {
certificate_checks: Default::default(),
oauth2: false,
};
param.save_legacy(&t).await?;
param.save(&t).await?;
assert_eq!(
t.get_config(Config::Addr).await?.unwrap(),
"alice@example.org"
@@ -420,7 +380,7 @@ mod tests {
assert_eq!(t.get_config(Config::SendPw).await?, None);
assert_eq!(t.get_config_int(Config::SendPort).await?, 2947);
assert_eq!(EnteredLoginParam::load_legacy(&t).await?, param);
assert_eq!(EnteredLoginParam::load(&t).await?, param);
Ok(())
}

View File

@@ -199,6 +199,7 @@ SELECT ?1, rfc724_mid, pre_rfc724_mid, timestamp, ?, ? FROM msgs WHERE id=?1
}
/// Returns detailed message information in a multi-line text form.
#[expect(clippy::arithmetic_side_effects)]
pub async fn get_info(self, context: &Context) -> Result<String> {
let msg = Message::load_from_db(context, self).await?;
@@ -824,6 +825,7 @@ impl Message {
///
/// Currently this includes `additional_text`, but this may change in future, when the UIs show
/// the necessary info themselves.
#[expect(clippy::arithmetic_side_effects)]
pub fn get_text(&self) -> String {
self.text.clone() + &self.additional_text
}

View File

@@ -194,7 +194,6 @@ fn new_address_with_name(name: &str, address: String) -> Address<'static> {
}
impl MimeFactory {
/// Returns `MimeFactory` for rendering `msg`.
#[expect(clippy::arithmetic_side_effects)]
pub async fn from_msg(context: &Context, msg: Message) -> Result<MimeFactory> {
let now = time();
@@ -464,21 +463,18 @@ impl MimeFactory {
.into_iter()
.filter(|id| *id != ContactId::SELF)
.collect();
if !matches!(
msg.param.get_cmd(),
SystemMessage::MemberRemovedFromGroup | SystemMessage::SecurejoinMessage
) && !matches!(chat.typ, Chattype::OutBroadcast | Chattype::InBroadcast)
if recipient_ids.len() == 1
&& !matches!(
msg.param.get_cmd(),
SystemMessage::MemberRemovedFromGroup | SystemMessage::SecurejoinMessage
)
&& !matches!(chat.typ, Chattype::OutBroadcast | Chattype::InBroadcast)
{
let origin = match recipient_ids.len() {
1 => Origin::OutgoingTo,
// Use the same origin as ChatId::accept_ex() does for groups.
_ => Origin::IncomingTo,
};
info!(
context,
"Scale up origin of {} recipients to {origin:?}.", chat.id
"Scale up origin of {} recipients to OutgoingTo.", chat.id
);
ContactId::scaleup_origin(context, &recipient_ids, origin).await?;
ContactId::scaleup_origin(context, &recipient_ids, Origin::OutgoingTo).await?;
}
if !msg.is_system_message()
@@ -1395,7 +1391,10 @@ impl MimeFactory {
}
}
if chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast {
if chat.typ == Chattype::Group
|| chat.typ == Chattype::OutBroadcast
|| chat.typ == Chattype::InBroadcast
{
headers.push((
"Chat-Group-Name",
mail_builder::headers::text::Text::new(chat.name.to_string()).into(),
@@ -1406,11 +1405,7 @@ impl MimeFactory {
mail_builder::headers::text::Text::new(ts.to_string()).into(),
));
}
}
if chat.typ == Chattype::Group
|| chat.typ == Chattype::OutBroadcast
|| chat.typ == Chattype::InBroadcast
{
match command {
SystemMessage::MemberRemovedFromGroup => {
let email_to_remove = msg.param.get(Param::Arg).unwrap_or_default();
@@ -1889,6 +1884,7 @@ impl MimeFactory {
}
/// Render an MDN
#[expect(clippy::arithmetic_side_effects)]
fn render_mdn(&mut self) -> Result<MimePart<'static>> {
// RFC 6522, this also requires the `report-type` parameter which is equal
// to the MIME subtype of the second body part of the multipart/report
@@ -2227,18 +2223,18 @@ fn should_encrypt_symmetrically(msg: &Message, chat: &Chat) -> bool {
/// rather than all recipients.
/// This function returns the fingerprint of the recipient the message should be sent to.
fn must_have_only_one_recipient<'a>(msg: &'a Message, chat: &Chat) -> Option<Result<&'a str>> {
if chat.typ != Chattype::OutBroadcast {
None
} else if let Some(fp) = msg.param.get(Param::Arg4) {
Some(Ok(fp))
} else if matches!(
msg.param.get_cmd(),
SystemMessage::MemberRemovedFromGroup | SystemMessage::MemberAddedToGroup
) {
Some(Err(format_err!("Missing removed/added member")))
} else {
None
if chat.typ == Chattype::OutBroadcast
&& matches!(
msg.param.get_cmd(),
SystemMessage::MemberRemovedFromGroup | SystemMessage::MemberAddedToGroup
)
{
let Some(fp) = msg.param.get(Param::Arg4) else {
return Some(Err(format_err!("Missing removed/added member")));
};
return Some(Ok(fp));
}
None
}
async fn build_body_file(context: &Context, msg: &Message) -> Result<MimePart<'static>> {

View File

@@ -269,6 +269,7 @@ impl MimeMessage {
///
/// This method has some side-effects,
/// such as saving blobs and saving found public keys to the database.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn from_bytes(context: &Context, body: &[u8]) -> Result<Self> {
let mail = mailparse::parse_mail(body)?;

View File

@@ -12,7 +12,7 @@ use tokio_io_timeout::TimeoutStream;
use crate::context::Context;
use crate::net::session::SessionStream;
use crate::net::tls::{SpkiHashStore, TlsSessionStore};
use crate::net::tls::TlsSessionStore;
use crate::sql::Sql;
use crate::tools::time;
@@ -130,8 +130,6 @@ pub(crate) async fn connect_tls_inner(
strict_tls: bool,
alpn: &str,
tls_session_store: &TlsSessionStore,
spki_hash_store: &SpkiHashStore,
sql: &Sql,
) -> Result<impl SessionStream + 'static> {
let use_sni = true;
let tcp_stream = connect_tcp_inner(addr).await?;
@@ -143,8 +141,6 @@ pub(crate) async fn connect_tls_inner(
alpn,
tcp_stream,
tls_session_store,
spki_hash_store,
sql,
)
.await?;
Ok(tls_stream)

View File

@@ -87,8 +87,6 @@ where
"",
proxy_stream,
&context.tls_session_store,
&context.spki_hash_store,
&context.sql,
)
.await?;
Box::new(tls_stream)
@@ -101,8 +99,6 @@ where
"",
tcp_stream,
&context.tls_session_store,
&context.spki_hash_store,
&context.sql,
)
.await?;
Box::new(tls_stream)

View File

@@ -171,6 +171,7 @@ pub enum ProxyConfig {
}
/// Constructs HTTP/1.1 `CONNECT` request for HTTP(S) proxy.
#[expect(clippy::arithmetic_side_effects)]
fn http_connect_request(host: &str, port: u16, auth: Option<(&str, &str)>) -> String {
// According to <https://datatracker.ietf.org/doc/html/rfc7230#section-5.4>
// clients MUST send `Host:` header in HTTP/1.1 requests,
@@ -319,6 +320,7 @@ impl ProxyConfig {
/// config into `proxy_url` if `proxy_url` is unset or empty.
///
/// Unsets `socks5_host`, `socks5_port`, `socks5_user` and `socks5_password` in any case.
#[expect(clippy::arithmetic_side_effects)]
async fn migrate_socks_config(sql: &Sql) -> Result<()> {
if sql.get_raw_config("proxy_url").await?.is_none() {
// Load legacy SOCKS5 settings.
@@ -434,8 +436,6 @@ impl ProxyConfig {
"",
tcp_stream,
&context.tls_session_store,
&context.spki_hash_store,
&context.sql,
)
.await?;
let auth = if let Some((username, password)) = &https_config.user_password {

View File

@@ -6,20 +6,13 @@ use std::sync::Arc;
use anyhow::Result;
use crate::net::session::SessionStream;
use crate::sql::Sql;
use crate::tools::time;
use tokio_rustls::rustls;
use tokio_rustls::rustls::client::ClientSessionStore;
use tokio_rustls::rustls::server::ParsedCertificate;
mod danger;
use danger::CustomCertificateVerifier;
use danger::NoCertificateVerification;
mod spki;
pub use spki::SpkiHashStore;
#[expect(clippy::too_many_arguments)]
pub async fn wrap_tls<'a>(
strict_tls: bool,
hostname: &str,
@@ -28,21 +21,10 @@ pub async fn wrap_tls<'a>(
alpn: &str,
stream: impl SessionStream + 'static,
tls_session_store: &TlsSessionStore,
spki_hash_store: &SpkiHashStore,
sql: &Sql,
) -> Result<impl SessionStream + 'a> {
if strict_tls {
let tls_stream = wrap_rustls(
hostname,
port,
use_sni,
alpn,
stream,
tls_session_store,
spki_hash_store,
sql,
)
.await?;
let tls_stream =
wrap_rustls(hostname, port, use_sni, alpn, stream, tls_session_store).await?;
let boxed_stream: Box<dyn SessionStream> = Box::new(tls_stream);
Ok(boxed_stream)
} else {
@@ -112,7 +94,6 @@ impl TlsSessionStore {
}
}
#[expect(clippy::too_many_arguments)]
pub async fn wrap_rustls<'a>(
hostname: &str,
port: u16,
@@ -120,11 +101,9 @@ pub async fn wrap_rustls<'a>(
alpn: &str,
stream: impl SessionStream + 'a,
tls_session_store: &TlsSessionStore,
spki_hash_store: &SpkiHashStore,
sql: &Sql,
) -> Result<impl SessionStream + 'a> {
let root_cert_store =
rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let mut root_cert_store = rustls::RootCertStore::empty();
root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let mut config = rustls::ClientConfig::builder()
.with_root_certificates(root_cert_store)
@@ -148,28 +127,20 @@ pub async fn wrap_rustls<'a>(
config.resumption = resumption;
config.enable_sni = use_sni;
config
.dangerous()
.set_certificate_verifier(Arc::new(CustomCertificateVerifier::new(
spki_hash_store.get_spki_hash(hostname, sql).await?,
)));
// Do not verify certificates for hostnames starting with `_`.
// They are used for servers with self-signed certificates, e.g. for local testing.
// Hostnames starting with `_` can have only self-signed TLS certificates or wildcard certificates.
// It is not possible to get valid non-wildcard TLS certificates because CA/Browser Forum requirements
// explicitly state that domains should start with a letter, digit or hyphen:
// https://github.com/cabforum/servercert/blob/24f38fd4765e019db8bb1a8c56bf63c7115ce0b0/docs/BR.md
if hostname.starts_with("_") {
config
.dangerous()
.set_certificate_verifier(Arc::new(NoCertificateVerification::new()));
}
let tls = tokio_rustls::TlsConnector::from(Arc::new(config));
let name = tokio_rustls::rustls::pki_types::ServerName::try_from(hostname)?.to_owned();
let tls_stream = tls.connect(name, stream).await?;
// Successfully connected.
// Remember SPKI hash to accept it later if certificate expires.
let (_io, client_connection) = tls_stream.get_ref();
if let Some(end_entity) = client_connection
.peer_certificates()
.and_then(|certs| certs.first())
{
let now = time();
let parsed_certificate = ParsedCertificate::try_from(end_entity)?;
let spki = parsed_certificate.subject_public_key_info();
spki_hash_store.save_spki(hostname, &spki, sql, now).await?;
}
Ok(tls_stream)
}

View File

@@ -1,93 +1,26 @@
//! Custom TLS verification.
//!
//! We want to accept expired certificates.
//! Dangerous TLS implementation of accepting invalid certificates for Rustls.
use rustls::RootCertStore;
use rustls::client::{verify_server_cert_signed_by_trust_anchor, verify_server_name};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::server::ParsedCertificate;
use tokio_rustls::rustls;
use crate::net::tls::spki::spki_hash;
#[derive(Debug)]
pub(super) struct CustomCertificateVerifier {
/// Root certificates.
root_cert_store: RootCertStore,
pub(super) struct NoCertificateVerification();
/// Expected SPKI hash as a base64 of SHA-256.
spki_hash: Option<String>,
}
impl CustomCertificateVerifier {
pub(super) fn new(spki_hash: Option<String>) -> Self {
let root_cert_store =
RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
Self {
root_cert_store,
spki_hash,
}
impl NoCertificateVerification {
pub(super) fn new() -> Self {
Self()
}
}
impl rustls::client::danger::ServerCertVerifier for CustomCertificateVerifier {
impl rustls::client::danger::ServerCertVerifier for NoCertificateVerification {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
intermediates: &[CertificateDer<'_>],
server_name: &ServerName<'_>,
// OCSP is a certificate revocation mechanism that is intentionally ignored.
// It is practically not used and is essentially deprecated
// in favor of Certificate Revocation Lists.
// Let's Encrypt has disabled OCSP responders in 2025:
// <https://letsencrypt.org/2025/08/06/ocsp-service-has-reached-end-of-life>.
// Theoretically checking of stapled OCSP responses could be implemented,
// but it is not interesting to implement it because it is not used
// by the servers: <https://github.com/rustls/webpki/issues/217>.
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
now: UnixTime,
_now: UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
let parsed_certificate = ParsedCertificate::try_from(end_entity)?;
let spki = parsed_certificate.subject_public_key_info();
let provider = rustls::crypto::ring::default_provider();
if let ServerName::DnsName(dns_name) = server_name
&& dns_name.as_ref().starts_with("_")
{
// Do not verify certificates for hostnames starting with `_`.
// They are used for servers with self-signed certificates, e.g. for local testing.
// Hostnames starting with `_` can have only self-signed TLS certificates or wildcard certificates.
// It is not possible to get valid non-wildcard TLS certificates because CA/Browser Forum requirements
// explicitly state that domains should start with a letter, digit or hyphen:
// https://github.com/cabforum/servercert/blob/24f38fd4765e019db8bb1a8c56bf63c7115ce0b0/docs/BR.md
} else if let Some(hash) = &self.spki_hash
&& spki_hash(&spki) == *hash
{
// Last time we successfully connected to this hostname with TLS checks,
// SPKI had this hash.
// It does not matter if certificate has now expired.
} else {
// verify_server_cert_signed_by_trust_anchor does no revocation checking:
// <https://docs.rs/rustls/0.23.37/rustls/client/fn.verify_server_cert_signed_by_trust_anchor.html>
// We don't do it either.
verify_server_cert_signed_by_trust_anchor(
&parsed_certificate,
&self.root_cert_store,
intermediates,
now,
provider.signature_verification_algorithms.all,
)?;
}
// Verify server name unconditionally.
//
// We do this even for self-signed certificates when hostname starts with `_`
// so we don't try to connect to captive portals
// and fail on MITM certificates if they are generated once
// and reused for all hostnames.
verify_server_name(&parsed_certificate, server_name)?;
Ok(rustls::client::danger::ServerCertVerified::assertion())
}

View File

@@ -1,133 +0,0 @@
//! SPKI hash storage.
//!
//! We store hashes of Subject Public Key Info from TLS certificates
//! after successful connection to allow connecting when
//! server certificate expires as long as the key is not changed.
use std::collections::BTreeMap;
use anyhow::Result;
use base64::Engine as _;
use parking_lot::RwLock;
use sha2::{Digest, Sha256};
use tokio_rustls::rustls::pki_types::SubjectPublicKeyInfoDer;
use crate::sql::Sql;
use crate::tools::time;
/// Calculates Subject Public Key Info SHA-256 hash and returns it as base64.
///
/// This is the same format as used in <https://www.rfc-editor.org/rfc/rfc7469>.
/// You can calculate the same hash for any remote host with
/// ```sh
/// openssl s_client -connect "$HOST:993" -servername "$HOST" </dev/null 2>/dev/null |
/// openssl x509 -pubkey -noout |
/// openssl pkey -pubin -outform der |
/// openssl dgst -sha256 -binary |
/// openssl enc -base64
/// ```
pub fn spki_hash(spki: &SubjectPublicKeyInfoDer) -> String {
let spki_hash = Sha256::digest(spki);
base64::engine::general_purpose::STANDARD.encode(spki_hash)
}
/// Write-through cache for SPKI hashes.
#[derive(Debug)]
pub struct SpkiHashStore {
/// Map from hostnames to base64 of SHA-256 hashes.
pub hash_store: RwLock<BTreeMap<String, String>>,
}
impl SpkiHashStore {
pub fn new() -> Self {
Self {
hash_store: RwLock::new(BTreeMap::new()),
}
}
/// Returns base64 of SPKI hash if we have previously successfully connected to given hostname.
pub async fn get_spki_hash(&self, hostname: &str, sql: &Sql) -> Result<Option<String>> {
if let Some(hash) = self.hash_store.read().get(hostname).cloned() {
return Ok(Some(hash));
}
match sql
.query_row_optional(
"SELECT spki_hash FROM tls_spki WHERE host=?",
(hostname,),
|row| {
let spki_hash: String = row.get(0)?;
Ok(spki_hash)
},
)
.await?
{
Some(hash) => {
self.hash_store
.write()
.insert(hostname.to_string(), hash.clone());
Ok(Some(hash))
}
None => Ok(None),
}
}
/// Saves SPKI hash after successful connection.
pub async fn save_spki(
&self,
hostname: &str,
spki: &SubjectPublicKeyInfoDer<'_>,
sql: &Sql,
timestamp: i64,
) -> Result<()> {
let hash = spki_hash(spki);
self.hash_store
.write()
.insert(hostname.to_string(), hash.clone());
sql.execute(
"INSERT OR REPLACE INTO tls_spki (host, spki_hash, timestamp) VALUES (?, ?, ?)",
(hostname, hash, timestamp),
)
.await?;
Ok(())
}
/// Removes stale entries from SPKI storage.
pub async fn cleanup(&self, sql: &Sql) -> Result<()> {
let now = time();
let removed_hosts = sql
.transaction(|transaction| {
let mut stmt = transaction
.prepare("DELETE FROM tls_spki WHERE ? > timestamp + ? RETURNING host")?;
let mut res = Vec::new();
for row in stmt.query_map((now, 30 * 24 * 60 * 60), |row| {
let host: String = row.get(0)?;
Ok(host)
})? {
res.push(row?);
}
// Fix timestamps that happen to be in the future
// if we had clock set incorrectly when the timestamp was stored.
// Otherwise entry may take more than 30 days to expire.
transaction.execute(
"UPDATE tls_spki SET timestamp = ?1 WHERE timestamp > ?1",
(now,),
)?;
Ok(res)
})
.await?;
let mut lock = self.hash_store.write();
for host in removed_hosts {
// We may accidentally remove a host that was added
// to the cache after SQL query but before we got
// the write lock on `hash_store`.
// It is unlikely and will only result
// in additional SQL query next time.
lock.remove(&host);
}
Ok(())
}
}

View File

@@ -136,10 +136,6 @@ pub enum Param {
/// For "MemberAddedToGroup" and "MemberRemovedFromGroup",
/// this is the fingerprint added to / removed from the group.
///
/// For messages resent when adding a new member to a broadcast channel,
/// this is the fingerprint of the added member;
/// the message must only be sent to this one member then.
///
/// For call messages, this is the end timsetamp.
Arg4 = b'H',

View File

@@ -24,6 +24,7 @@ pub struct PlainText {
impl PlainText {
/// Convert plain text to HTML.
/// The function handles quotes, links, fixed and floating text paragraphs.
#[expect(clippy::arithmetic_side_effects)]
pub fn to_html(&self) -> String {
static LINKIFY_MAIL_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\b([\w.\-+]+@[\w.\-]+)\b").unwrap());

View File

@@ -234,6 +234,34 @@ static P_BLUEWIN_CH: Provider = Provider {
oauth2_authorizer: None,
};
// buzon.uy.md: buzon.uy
static P_BUZON_UY: Provider = Provider {
id: "buzon.uy",
status: Status::Ok,
before_login_hint: "",
after_login_hint: "",
overview_page: "https://providers.delta.chat/buzon-uy",
server: &[
Server {
protocol: Imap,
socket: Starttls,
hostname: "mail.buzon.uy",
port: 143,
username_pattern: Email,
},
Server {
protocol: Smtp,
socket: Starttls,
hostname: "mail.buzon.uy",
port: 587,
username_pattern: Email,
},
],
opt: ProviderOptions::new(),
config_defaults: None,
oauth2_authorizer: None,
};
// chello.at.md: chello.at
static P_CHELLO_AT: Provider = Provider {
id: "chello.at",
@@ -275,6 +303,48 @@ static P_COMCAST: Provider = Provider {
oauth2_authorizer: None,
};
// daleth.cafe.md: daleth.cafe
static P_DALETH_CAFE: Provider = Provider {
id: "daleth.cafe",
status: Status::Ok,
before_login_hint: "",
after_login_hint: "",
overview_page: "https://providers.delta.chat/daleth-cafe",
server: &[
Server {
protocol: Imap,
socket: Ssl,
hostname: "daleth.cafe",
port: 993,
username_pattern: Email,
},
Server {
protocol: Smtp,
socket: Ssl,
hostname: "daleth.cafe",
port: 465,
username_pattern: Email,
},
Server {
protocol: Imap,
socket: Starttls,
hostname: "daleth.cafe",
port: 143,
username_pattern: Email,
},
Server {
protocol: Smtp,
socket: Starttls,
hostname: "daleth.cafe",
port: 587,
username_pattern: Email,
},
],
opt: ProviderOptions::new(),
config_defaults: None,
oauth2_authorizer: None,
};
// dismail.de.md: dismail.de
static P_DISMAIL_DE: Provider = Provider {
id: "dismail.de",
@@ -426,6 +496,22 @@ static P_FIREMAIL_DE: Provider = Provider {
oauth2_authorizer: None,
};
// five.chat.md: five.chat
static P_FIVE_CHAT: Provider = Provider {
id: "five.chat",
status: Status::Ok,
before_login_hint: "",
after_login_hint: "",
overview_page: "https://providers.delta.chat/five-chat",
server: &[],
opt: ProviderOptions::new(),
config_defaults: Some(&[ConfigDefault {
key: Config::BccSelf,
value: "1",
}]),
oauth2_authorizer: None,
};
// freenet.de.md: freenet.de
static P_FREENET_DE: Provider = Provider {
id: "freenet.de",
@@ -543,10 +629,16 @@ static P_HERMES_RADIO: Provider = Provider {
strict_tls: false,
..ProviderOptions::new()
},
config_defaults: Some(&[ConfigDefault {
key: Config::MdnsEnabled,
value: "0",
}]),
config_defaults: Some(&[
ConfigDefault {
key: Config::MdnsEnabled,
value: "0",
},
ConfigDefault {
key: Config::ShowEmails,
value: "2",
},
]),
oauth2_authorizer: None,
};
@@ -827,6 +919,90 @@ static P_MAILO_COM: Provider = Provider {
oauth2_authorizer: None,
};
// mehl.cloud.md: mehl.cloud
static P_MEHL_CLOUD: Provider = Provider {
id: "mehl.cloud",
status: Status::Ok,
before_login_hint: "",
after_login_hint: "",
overview_page: "https://providers.delta.chat/mehl-cloud",
server: &[
Server {
protocol: Imap,
socket: Ssl,
hostname: "mehl.cloud",
port: 443,
username_pattern: Email,
},
Server {
protocol: Smtp,
socket: Ssl,
hostname: "mehl.cloud",
port: 443,
username_pattern: Email,
},
Server {
protocol: Imap,
socket: Ssl,
hostname: "mehl.cloud",
port: 993,
username_pattern: Email,
},
Server {
protocol: Smtp,
socket: Ssl,
hostname: "mehl.cloud",
port: 465,
username_pattern: Email,
},
Server {
protocol: Imap,
socket: Starttls,
hostname: "mehl.cloud",
port: 143,
username_pattern: Email,
},
Server {
protocol: Smtp,
socket: Starttls,
hostname: "mehl.cloud",
port: 587,
username_pattern: Email,
},
],
opt: ProviderOptions::new(),
config_defaults: None,
oauth2_authorizer: None,
};
// mehl.store.md: mehl.store, ende.in.net, l2i.top, szh.homes, sls.post.in, ente.quest, ente.cfd, nein.jetzt
static P_MEHL_STORE: Provider = Provider {
id: "mehl.store",
status: Status::Ok,
before_login_hint: "",
after_login_hint: "This account provides 3GB storage for eMails and the possibility to access a NEXTCLOUD-instance by using the email-credits!",
overview_page: "https://providers.delta.chat/mehl-store",
server: &[
Server {
protocol: Imap,
socket: Ssl,
hostname: "mail.ende.in.net",
port: 993,
username_pattern: Email,
},
Server {
protocol: Smtp,
socket: Starttls,
hostname: "mail.ende.in.net",
port: 587,
username_pattern: Email,
},
],
opt: ProviderOptions::new(),
config_defaults: None,
oauth2_authorizer: None,
};
// migadu.md: migadu.com
static P_MIGADU: Provider = Provider {
id: "migadu",
@@ -1074,8 +1250,8 @@ static P_OUVATON_COOP: Provider = Provider {
// posteo.md: posteo.de, posteo.af, posteo.at, posteo.be, posteo.ca, posteo.ch, posteo.cl, posteo.co, posteo.co.uk, posteo.com, posteo.com.br, posteo.cr, posteo.cz, posteo.dk, posteo.ee, posteo.es, posteo.eu, posteo.fi, posteo.gl, posteo.gr, posteo.hn, posteo.hr, posteo.hu, posteo.ie, posteo.in, posteo.is, posteo.it, posteo.jp, posteo.la, posteo.li, posteo.lt, posteo.lu, posteo.me, posteo.mx, posteo.my, posteo.net, posteo.nl, posteo.no, posteo.nz, posteo.org, posteo.pe, posteo.pl, posteo.pm, posteo.pt, posteo.ro, posteo.ru, posteo.se, posteo.sg, posteo.si, posteo.tn, posteo.uk, posteo.us
static P_POSTEO: Provider = Provider {
id: "posteo",
status: Status::Preparation,
before_login_hint: "You must create an app-specific password before you can log in.",
status: Status::Ok,
before_login_hint: "",
after_login_hint: "",
overview_page: "https://providers.delta.chat/posteo",
server: &[
@@ -1386,6 +1562,51 @@ static P_T_ONLINE: Provider = Provider {
oauth2_authorizer: None,
};
// testrun.md: testrun.org
static P_TESTRUN: Provider = Provider {
id: "testrun",
status: Status::Ok,
before_login_hint: "",
after_login_hint: "",
overview_page: "https://providers.delta.chat/testrun",
server: &[
Server {
protocol: Imap,
socket: Ssl,
hostname: "testrun.org",
port: 993,
username_pattern: Email,
},
Server {
protocol: Smtp,
socket: Ssl,
hostname: "testrun.org",
port: 465,
username_pattern: Email,
},
Server {
protocol: Imap,
socket: Starttls,
hostname: "testrun.org",
port: 143,
username_pattern: Email,
},
Server {
protocol: Smtp,
socket: Starttls,
hostname: "testrun.org",
port: 587,
username_pattern: Email,
},
],
opt: ProviderOptions::new(),
config_defaults: Some(&[ConfigDefault {
key: Config::BccSelf,
value: "1",
}]),
oauth2_authorizer: None,
};
// tiscali.it.md: tiscali.it
static P_TISCALI_IT: Provider = Provider {
id: "tiscali.it",
@@ -1783,7 +2004,7 @@ static P_ZOHO: Provider = Provider {
oauth2_authorizer: None,
};
pub(crate) static PROVIDER_DATA: [(&str, &Provider); 521] = [
pub(crate) static PROVIDER_DATA: [(&str, &Provider); 534] = [
("163.com", &P_163),
("aktivix.org", &P_AKTIVIX_ORG),
("aliyun.com", &P_ALIYUN),
@@ -1793,9 +2014,11 @@ pub(crate) static PROVIDER_DATA: [(&str, &Provider); 521] = [
("delta.blinzeln.de", &P_BLINDZELN_ORG),
("delta.blindzeln.org", &P_BLINDZELN_ORG),
("bluewin.ch", &P_BLUEWIN_CH),
("buzon.uy", &P_BUZON_UY),
("chello.at", &P_CHELLO_AT),
("xfinity.com", &P_COMCAST),
("comcast.net", &P_COMCAST),
("daleth.cafe", &P_DALETH_CAFE),
("dismail.de", &P_DISMAIL_DE),
("disroot.org", &P_DISROOT),
("e.email", &P_E_EMAIL),
@@ -1922,6 +2145,7 @@ pub(crate) static PROVIDER_DATA: [(&str, &Provider); 521] = [
("your-mail.com", &P_FASTMAIL),
("firemail.at", &P_FIREMAIL_DE),
("firemail.de", &P_FIREMAIL_DE),
("five.chat", &P_FIVE_CHAT),
("freenet.de", &P_FREENET_DE),
("gmail.com", &P_GMAIL),
("googlemail.com", &P_GMAIL),
@@ -2144,6 +2368,15 @@ pub(crate) static PROVIDER_DATA: [(&str, &Provider); 521] = [
("mailbox.org", &P_MAILBOX_ORG),
("secure.mailbox.org", &P_MAILBOX_ORG),
("mailo.com", &P_MAILO_COM),
("mehl.cloud", &P_MEHL_CLOUD),
("mehl.store", &P_MEHL_STORE),
("ende.in.net", &P_MEHL_STORE),
("l2i.top", &P_MEHL_STORE),
("szh.homes", &P_MEHL_STORE),
("sls.post.in", &P_MEHL_STORE),
("ente.quest", &P_MEHL_STORE),
("ente.cfd", &P_MEHL_STORE),
("nein.jetzt", &P_MEHL_STORE),
("migadu.com", &P_MIGADU),
("nauta.cu", &P_NAUTA_CU),
("naver.com", &P_NAVER),
@@ -2236,6 +2469,7 @@ pub(crate) static PROVIDER_DATA: [(&str, &Provider); 521] = [
("systemli.org", &P_SYSTEMLI_ORG),
("t-online.de", &P_T_ONLINE),
("magenta.de", &P_T_ONLINE),
("testrun.org", &P_TESTRUN),
("tiscali.it", &P_TISCALI_IT),
("tutanota.com", &P_TUTANOTA),
("tutanota.de", &P_TUTANOTA),
@@ -2318,8 +2552,10 @@ pub(crate) static PROVIDER_IDS: LazyLock<HashMap<&'static str, &'static Provider
("autistici.org", &P_AUTISTICI_ORG),
("blindzeln.org", &P_BLINDZELN_ORG),
("bluewin.ch", &P_BLUEWIN_CH),
("buzon.uy", &P_BUZON_UY),
("chello.at", &P_CHELLO_AT),
("comcast", &P_COMCAST),
("daleth.cafe", &P_DALETH_CAFE),
("dismail.de", &P_DISMAIL_DE),
("disroot", &P_DISROOT),
("e.email", &P_E_EMAIL),
@@ -2327,6 +2563,7 @@ pub(crate) static PROVIDER_IDS: LazyLock<HashMap<&'static str, &'static Provider
("example.com", &P_EXAMPLE_COM),
("fastmail", &P_FASTMAIL),
("firemail.de", &P_FIREMAIL_DE),
("five.chat", &P_FIVE_CHAT),
("freenet.de", &P_FREENET_DE),
("gmail", &P_GMAIL),
("gmx.net", &P_GMX_NET),
@@ -2344,6 +2581,8 @@ pub(crate) static PROVIDER_IDS: LazyLock<HashMap<&'static str, &'static Provider
("mail2tor", &P_MAIL2TOR),
("mailbox.org", &P_MAILBOX_ORG),
("mailo.com", &P_MAILO_COM),
("mehl.cloud", &P_MEHL_CLOUD),
("mehl.store", &P_MEHL_STORE),
("migadu", &P_MIGADU),
("nauta.cu", &P_NAUTA_CU),
("naver", &P_NAVER),
@@ -2363,6 +2602,7 @@ pub(crate) static PROVIDER_IDS: LazyLock<HashMap<&'static str, &'static Provider
("systemausfall.org", &P_SYSTEMAUSFALL_ORG),
("systemli.org", &P_SYSTEMLI_ORG),
("t-online", &P_T_ONLINE),
("testrun", &P_TESTRUN),
("tiscali.it", &P_TISCALI_IT),
("tutanota", &P_TUTANOTA),
("ukr.net", &P_UKR_NET),
@@ -2382,4 +2622,4 @@ pub(crate) static PROVIDER_IDS: LazyLock<HashMap<&'static str, &'static Provider
});
pub static _PROVIDER_UPDATED: LazyLock<chrono::NaiveDate> =
LazyLock::new(|| chrono::NaiveDate::from_ymd_opt(2026, 4, 21).unwrap());
LazyLock::new(|| chrono::NaiveDate::from_ymd_opt(2026, 1, 28).unwrap());

View File

@@ -17,7 +17,7 @@ use crate::config::Config;
use crate::contact::{Contact, ContactId, Origin};
use crate::context::Context;
use crate::key::Fingerprint;
use crate::login_param::{EnteredCertificateChecks, EnteredImapLoginParam, EnteredLoginParam};
use crate::login_param::{EnteredCertificateChecks, EnteredLoginParam, EnteredServerLoginParam};
use crate::net::http::post_empty;
use crate::net::proxy::{DEFAULT_SOCKS_PORT, ProxyConfig};
use crate::token;
@@ -41,7 +41,7 @@ pub(crate) const DCBACKUP_SCHEME_PREFIX: &str = "DCBACKUP";
/// Version written to Backups and Backup-QR-Codes.
/// Imports will fail when they have a larger version.
pub(crate) const DCBACKUP_VERSION: i32 = 5;
pub(crate) const DCBACKUP_VERSION: i32 = 4;
/// Scanned QR code.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -714,6 +714,7 @@ fn decode_account(qr: &str) -> Result<Qr> {
}
/// scheme: `https://t.me/socks?server=foo&port=123` or `https://t.me/socks?server=1.2.3.4&port=123`
#[expect(clippy::arithmetic_side_effects)]
fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result<Qr> {
let url = url::Url::parse(qr).context("Invalid t.me/socks url")?;
@@ -823,7 +824,7 @@ pub(crate) async fn login_param_from_account_qr(
let param = EnteredLoginParam {
addr,
imap: EnteredImapLoginParam {
imap: EnteredServerLoginParam {
password,
..Default::default()
},
@@ -843,7 +844,7 @@ pub(crate) async fn login_param_from_account_qr(
let param = EnteredLoginParam {
addr: email,
imap: EnteredImapLoginParam {
imap: EnteredServerLoginParam {
password,
..Default::default()
},

View File

@@ -5,9 +5,7 @@ use anyhow::{Context as _, Result, bail};
use deltachat_contact_tools::may_be_valid_addr;
use super::{DCLOGIN_SCHEME, Qr};
use crate::login_param::{
EnteredCertificateChecks, EnteredImapLoginParam, EnteredLoginParam, EnteredSmtpLoginParam,
};
use crate::login_param::{EnteredCertificateChecks, EnteredLoginParam, EnteredServerLoginParam};
use crate::provider::Socket;
/// Options for `dclogin:` scheme.
@@ -180,15 +178,14 @@ pub(crate) fn login_param_from_login_qr(
} => {
let param = EnteredLoginParam {
addr: addr.to_string(),
imap: EnteredImapLoginParam {
imap: EnteredServerLoginParam {
server: imap_host.unwrap_or_default(),
port: imap_port.unwrap_or_default(),
folder: "INBOX".to_string(),
security: imap_security.unwrap_or_default(),
user: imap_username.unwrap_or_default(),
password: imap_password.unwrap_or(mail_pw),
},
smtp: EnteredSmtpLoginParam {
smtp: EnteredServerLoginParam {
server: smtp_host.unwrap_or_default(),
port: smtp_port.unwrap_or_default(),
security: smtp_security.unwrap_or_default(),

View File

@@ -709,7 +709,7 @@ async fn test_decode_dclogin_advanced_options() -> Result<()> {
assert_eq!(param.smtp.security, Socket::Plain);
// `sc` option is actually ignored and `ic` is used instead
// because `smtp_certificate_checks` has been removed.
// because `smtp_certificate_checks` is deprecated.
assert_eq!(param.certificate_checks, EnteredCertificateChecks::Strict);
Ok(())

View File

@@ -15,6 +15,7 @@ use crate::securejoin;
use crate::stock_str::{self, backup_transfer_qr};
/// Create a QR code from any input data.
#[expect(clippy::arithmetic_side_effects)]
pub fn create_qr_svg(qrcode_content: &str) -> Result<String> {
let all_size = 512.0;
let qr_code_size = 416.0;
@@ -175,6 +176,7 @@ async fn self_info(context: &Context) -> Result<(Option<Vec<u8>>, String, String
Ok((avatar, displayname, addr, color))
}
#[expect(clippy::arithmetic_side_effects)]
fn inner_generate_secure_join_qr_code(
qrcode_description: &str,
qrcode_content: &str,

View File

@@ -9,6 +9,7 @@ use async_imap::types::{Quota, QuotaResource};
use crate::chat::add_device_msg_with_importance;
use crate::config::Config;
use crate::context::Context;
use crate::imap::get_watched_folders;
use crate::imap::session::Session as ImapSession;
use crate::log::warn;
use crate::message::Message;
@@ -47,24 +48,26 @@ pub struct QuotaInfo {
async fn get_unique_quota_roots_and_usage(
session: &mut ImapSession,
folder: &str,
folders: Vec<String>,
) -> Result<BTreeMap<String, Vec<QuotaResource>>> {
let mut unique_quota_roots: BTreeMap<String, Vec<QuotaResource>> = BTreeMap::new();
let (quota_roots, quotas) = &session.get_quota_root(folder).await?;
// if there are new quota roots found in this imap folder, add them to the list
for qr_entries in quota_roots {
for quota_root_name in &qr_entries.quota_root_names {
// the quota for that quota root
let quota: Quota = quotas
.iter()
.find(|q| &q.root_name == quota_root_name)
.cloned()
.context("quota_root should have a quota")?;
// replace old quotas, because between fetching quotaroots for folders,
// messages could be received and so the usage could have been changed
*unique_quota_roots
.entry(quota_root_name.clone())
.or_default() = quota.resources;
for folder in folders {
let (quota_roots, quotas) = &session.get_quota_root(&folder).await?;
// if there are new quota roots found in this imap folder, add them to the list
for qr_entries in quota_roots {
for quota_root_name in &qr_entries.quota_root_names {
// the quota for that quota root
let quota: Quota = quotas
.iter()
.find(|q| &q.root_name == quota_root_name)
.cloned()
.context("quota_root should have a quota")?;
// replace old quotas, because between fetching quotaroots for folders,
// messages could be received and so the usage could have been changed
*unique_quota_roots
.entry(quota_root_name.clone())
.or_default() = quota.resources;
}
}
}
Ok(unique_quota_roots)
@@ -119,17 +122,14 @@ impl Context {
/// As the message is added only once, the user is not spammed
/// in case for some providers the quota is always at ~100%
/// and new space is allocated as needed.
pub(crate) async fn update_recent_quota(
&self,
session: &mut ImapSession,
folder: &str,
) -> Result<()> {
pub(crate) async fn update_recent_quota(&self, session: &mut ImapSession) -> Result<()> {
let transport_id = session.transport_id();
info!(self, "Transport {transport_id}: Updating quota.");
let quota = if session.can_check_quota() {
get_unique_quota_roots_and_usage(session, folder).await
let folders = get_watched_folders(self).await?;
get_unique_quota_roots_and_usage(session, folders).await
} else {
Err(anyhow!(stock_str::not_supported_by_provider(self)))
};

View File

@@ -1,6 +1,6 @@
//! # Reactions.
//!
//! Reactions are short messages representing an emoji sent in reply to
//! Reactions are short messages consisting of emojis sent in reply to
//! messages. Unlike normal messages which are added to the end of the chat,
//! reactions are supposed to be displayed near the original messages.
//!
@@ -11,7 +11,7 @@
//! [XEP-0444](https://xmpp.org/extensions/xep-0444.html) section
//! "3.2 Updating reactions to a message". Received reactions override
//! all previously received reactions from the same user and it is
//! possible to remove the reaction by sending an empty string as a reaction,
//! possible to remove all reactions by sending an empty string as a reaction,
//! even though RFC 9078 requires at least one emoji to be sent.
use std::cmp::Ordering;
@@ -29,7 +29,9 @@ use crate::events::EventType;
use crate::message::{Message, MsgId, rfc724_mid_exists};
use crate::param::Param;
/// A single reaction.
/// A single reaction consisting of multiple emoji sequences.
///
/// It is guaranteed to have all emojis sorted and deduplicated inside.
#[derive(Debug, Default, Clone, Deserialize, Eq, PartialEq, Serialize)]
pub struct Reaction {
/// Canonical representation of reaction as a string of space-separated emojis.
@@ -40,8 +42,11 @@ pub struct Reaction {
// FromStr requires error type and reaction parsing never returns an
// error.
impl From<&str> for Reaction {
/// Convert a `&str` into a `Reaction`.
/// Everything after the first whitespace is ignored.
/// Parses a string containing a reaction.
///
/// Reaction string is separated by spaces or tabs (`WSP` in ABNF),
/// but this function accepts any ASCII whitespace, so even a CRLF at
/// the end of string is acceptable.
///
/// Any short enough string is accepted as a reaction to avoid the
/// complexity of validating emoji sequences as required by RFC
@@ -52,27 +57,42 @@ impl From<&str> for Reaction {
/// such as sending large numbers of large messages, and should be
/// dealt with the same way, e.g. by blocking the user.
fn from(reaction: &str) -> Self {
let reaction: &str = reaction
let mut emojis: Vec<&str> = reaction
.split_ascii_whitespace()
.next()
.filter(|&emoji| emoji.len() < 30)
.unwrap_or("");
Self {
reaction: reaction.to_string(),
}
.collect();
emojis.sort_unstable();
emojis.dedup();
let reaction = emojis.join(" ");
Self { reaction }
}
}
impl Reaction {
/// Returns true if reaction contains no emoji.
/// Returns true if reaction contains no emojis.
pub fn is_empty(&self) -> bool {
self.reaction.is_empty()
}
/// Returns a string representing the emoji.
/// Returns a vector of emojis composing a reaction.
pub fn emojis(&self) -> Vec<&str> {
self.reaction.split(' ').collect()
}
/// Returns space-separated string of emojis
pub fn as_str(&self) -> &str {
&self.reaction
}
/// Appends emojis from another reaction to this reaction.
pub fn add(&self, other: Self) -> Self {
let mut emojis: Vec<&str> = self.emojis();
emojis.append(&mut other.emojis());
emojis.sort_unstable();
emojis.dedup();
let reaction = emojis.join(" ");
Self { reaction }
}
}
/// Structure representing all reactions to a particular message.
@@ -106,10 +126,12 @@ impl Reactions {
pub fn emoji_frequencies(&self) -> BTreeMap<String, usize> {
let mut emoji_frequencies: BTreeMap<String, usize> = BTreeMap::new();
for reaction in self.reactions.values() {
emoji_frequencies
.entry(reaction.as_str().to_string())
.and_modify(|x| *x += 1)
.or_insert(1);
for emoji in reaction.emojis() {
emoji_frequencies
.entry(emoji.to_string())
.and_modify(|x| *x += 1)
.or_insert(1);
}
}
emoji_frequencies
}
@@ -130,11 +152,6 @@ impl Reactions {
});
emoji_frequencies
}
/// Returns an iterator of the contacts that reacted and their corresponding reactions.
pub fn iter(&self) -> impl Iterator<Item = (&ContactId, &Reaction)> {
self.reactions.iter()
}
}
impl fmt::Display for Reactions {
@@ -206,7 +223,7 @@ async fn set_msg_id_reaction(
/// Sends a reaction to message `msg_id`, overriding previously sent reactions.
///
/// `reaction` is a string consisting of a single emoji. Use
/// `reaction` is a string consisting of space-separated emoji. Use
/// empty string to retract a reaction.
pub async fn send_reaction(context: &Context, msg_id: MsgId, reaction: &str) -> Result<MsgId> {
let msg = Message::load_from_db(context, msg_id).await?;
@@ -234,12 +251,24 @@ pub async fn send_reaction(context: &Context, msg_id: MsgId, reaction: &str) ->
Ok(reaction_msg_id)
}
/// Adds given reaction to message `msg_id` and sends an update.
///
/// This can be used to implement advanced clients that allow reacting
/// with multiple emojis. For a simple messenger UI, you probably want
/// to use [`send_reaction()`] instead so reacting with a new emoji
/// removes previous emoji at the same time.
pub async fn add_reaction(context: &Context, msg_id: MsgId, reaction: &str) -> Result<MsgId> {
let self_reaction = get_self_reaction(context, msg_id).await?;
let reaction = self_reaction.add(Reaction::from(reaction));
send_reaction(context, msg_id, reaction.as_str()).await
}
/// Updates reaction of `contact_id` on the message with `in_reply_to`
/// Message-ID. If no such message is found in the database, reaction
/// is ignored.
///
/// `reaction` is string representing the emoji. It can be empty
/// if contact wants to remove the reaction.
/// `reaction` is a space-separated string of emojis. It can be empty
/// if contact wants to remove all reactions.
pub(crate) async fn set_msg_reaction(
context: &Context,
in_reply_to: &str,
@@ -272,9 +301,26 @@ pub(crate) async fn set_msg_reaction(
Ok(())
}
/// Get our own reaction for a given message.
async fn get_self_reaction(context: &Context, msg_id: MsgId) -> Result<Reaction> {
let reaction_str: Option<String> = context
.sql
.query_get_value(
"SELECT reaction
FROM reactions
WHERE msg_id=? AND contact_id=?",
(msg_id, ContactId::SELF),
)
.await?;
Ok(reaction_str
.as_deref()
.map(Reaction::from)
.unwrap_or_default())
}
/// Returns a structure containing all reactions to the message.
pub async fn get_msg_reactions(context: &Context, msg_id: MsgId) -> Result<Reactions> {
let mut reactions: BTreeMap<ContactId, Reaction> = context
let reactions: BTreeMap<ContactId, Reaction> = context
.sql
.query_map_collect(
"SELECT contact_id, reaction FROM reactions WHERE msg_id=?",
@@ -286,7 +332,6 @@ pub async fn get_msg_reactions(context: &Context, msg_id: MsgId) -> Result<React
},
)
.await?;
reactions.retain(|_contact, reaction| !reaction.is_empty());
Ok(Reactions { reactions })
}
@@ -361,32 +406,46 @@ mod tests {
#[test]
fn test_parse_reaction() {
// Check that basic set of emojis from RFC 9078 is supported.
assert_eq!(Reaction::from("👍").as_str(), "👍");
assert_eq!(Reaction::from("👎").as_str(), "👎");
assert_eq!(Reaction::from("😀").as_str(), "😀");
assert_eq!(Reaction::from("").as_str(), "");
assert_eq!(Reaction::from("😢").as_str(), "😢");
assert_eq!(Reaction::from("👍").emojis(), vec!["👍"]);
assert_eq!(Reaction::from("👎").emojis(), vec!["👎"]);
assert_eq!(Reaction::from("😀").emojis(), vec!["😀"]);
assert_eq!(Reaction::from("").emojis(), vec![""]);
assert_eq!(Reaction::from("😢").emojis(), vec!["😢"]);
// Empty string can be used to remove all reactions.
assert!(Reaction::from("").is_empty());
// Short strings can be used as emojis, could be used to add
// support for custom emojis via emoji shortcodes.
assert_eq!(Reaction::from(":deltacat:").as_str(), ":deltacat:");
assert_eq!(Reaction::from(":deltacat:").emojis(), vec![":deltacat:"]);
// Check that long strings are not valid emojis.
assert!(
Reaction::from(":foobarbazquuxaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:").is_empty()
);
// Multiple reactions separated by spaces or tabs are not supported.
assert_eq!(Reaction::from("👍 ❤").as_str(), "👍");
assert_eq!(Reaction::from("👍\t").as_str(), "👍");
// Multiple reactions separated by spaces or tabs are supported.
assert_eq!(Reaction::from("👍 ❤").emojis(), vec!["", "👍"]);
assert_eq!(Reaction::from("👍\t").emojis(), vec!["", "👍"]);
assert_eq!(Reaction::from("👍\t:foo: ❤").as_str(), "👍");
assert_eq!(Reaction::from("👍\t:foo: ❤").as_str(), "👍");
// Invalid emojis are removed, but valid emojis are retained.
assert_eq!(
Reaction::from("👍\t:foo: ❤").emojis(),
vec![":foo:", "", "👍"]
);
assert_eq!(Reaction::from("👍\t:foo: ❤").as_str(), ":foo: ❤ 👍");
assert_eq!(Reaction::from("👍 👍").as_str(), "👍");
// Duplicates are removed.
assert_eq!(Reaction::from("👍 👍").emojis(), vec!["👍"]);
}
#[test]
fn test_add_reaction() {
let reaction1 = Reaction::from("👍 😀");
let reaction2 = Reaction::from("");
let reaction_sum = reaction1.add(reaction2);
assert_eq!(reaction_sum.emojis(), vec!["", "👍", "😀"]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -452,7 +511,7 @@ Content-Disposition: reaction\n\
assert_eq!(contacts.first(), Some(&bob_id));
let bob_reaction = reactions.get(bob_id);
assert_eq!(bob_reaction.is_empty(), false);
assert_eq!(bob_reaction.as_str(), "👍");
assert_eq!(bob_reaction.emojis(), vec!["👍"]);
assert_eq!(bob_reaction.as_str(), "👍");
// Alice receives reaction to her message from Bob with a footer.
@@ -671,7 +730,7 @@ Content-Disposition: reaction\n\
let bob_id = contacts.first().unwrap();
let bob_reaction = reactions.get(*bob_id);
assert_eq!(bob_reaction.is_empty(), false);
assert_eq!(bob_reaction.as_str(), "👍");
assert_eq!(bob_reaction.emojis(), vec!["👍"]);
assert_eq!(bob_reaction.as_str(), "👍");
expect_reactions_changed_event(&alice, chat_alice.id, alice_msg.sender_msg_id, *bob_id)
.await?;
@@ -710,16 +769,15 @@ Content-Disposition: reaction\n\
);
// Alice reacts to own message.
// Trying to set multiple reactions at once is not allowed.
send_reaction(&alice, alice_msg.sender_msg_id, "👍 😀")
.await
.unwrap();
let reactions = get_msg_reactions(&alice, alice_msg.sender_msg_id).await?;
assert_eq!(reactions.to_string(), "👍2");
assert_eq!(reactions.to_string(), "👍2 😀1");
assert_eq!(
reactions.emoji_sorted_by_frequency(),
vec![("👍".to_string(), 2)]
vec![("👍".to_string(), 2), ("😀".to_string(), 1)]
);
Ok(())

View File

@@ -1313,9 +1313,8 @@ async fn decide_chat_assignment(
..
} = &mime_parser.pre_message
{
let post_msg_exists = if let Some((msg_id, not_downloaded)) =
message::rfc724_mid_exists_ex(context, post_msg_rfc724_mid, "download_state<>0").await?
{
let msg_id = rfc724_mid_exists(context, post_msg_rfc724_mid).await?;
if let Some(msg_id) = msg_id {
context
.sql
.execute(
@@ -1323,10 +1322,8 @@ async fn decide_chat_assignment(
(rfc724_mid, msg_id),
)
.await?;
!not_downloaded
} else {
false
};
}
let post_msg_exists = msg_id.is_some();
info!(
context,
"Message {rfc724_mid} is a pre-message for {post_msg_rfc724_mid} (post_msg_exists:{post_msg_exists})."

View File

@@ -3875,17 +3875,21 @@ async fn test_group_contacts_goto_bottom() -> Result<()> {
assert_eq!(Contact::get_all(bob, 0, None).await?.len(), 0);
bob_chat_id.accept(bob).await?;
let contacts = Contact::get_all(bob, 0, None).await?;
let bob_fiona_id = bob.add_or_lookup_contact_id(fiona).await;
// Fiona hasn't been online, so she goes after Alice.
assert_eq!(contacts.len(), 2);
let bob_fiona_id = bob.add_or_lookup_contact_id(fiona).await;
assert_eq!(contacts[1], bob_fiona_id);
let bob_fiona_chat_id = ChatId::create_for_contact(bob, bob_fiona_id).await?;
ChatId::create_for_contact(bob, bob_fiona_id).await?;
let contacts = Contact::get_all(bob, 0, None).await?;
assert_eq!(contacts.len(), 2);
assert_eq!(contacts[0], bob_fiona_id);
send_text_msg(bob, bob_chat_id, "Hi all".to_string()).await?;
send_text_msg(
bob,
bob_chat_id,
"Hi Alice, stay down in my contact list".to_string(),
)
.await?;
bob.pop_sent_msg().await;
let contacts = Contact::get_all(bob, 0, None).await?;
assert_eq!(contacts[0], bob_fiona_id);
@@ -3901,13 +3905,6 @@ async fn test_group_contacts_goto_bottom() -> Result<()> {
bob.pop_sent_msg().await;
let contacts = Contact::get_all(bob, 0, None).await?;
let bob_alice_id = bob.add_or_lookup_contact_id(alice).await;
// As the group only contains Alice, the sent message promotes her in the contact list.
assert_eq!(contacts[0], bob_alice_id);
send_text_msg(bob, bob_fiona_chat_id, "Hi Fiona".to_string()).await?;
bob.pop_sent_msg().await;
let contacts = Contact::get_all(bob, 0, None).await?;
// Alice is still the 0th contact because Fiona hasn't been online.
assert_eq!(contacts[0], bob_alice_id);
Ok(())
}

View File

@@ -17,7 +17,7 @@ use crate::context::Context;
use crate::download::{download_known_post_messages_without_pre_message, download_msgs};
use crate::ephemeral::{self, delete_expired_imap_messages};
use crate::events::EventType;
use crate::imap::{Imap, session::Session};
use crate::imap::{FolderMeaning, Imap, session::Session};
use crate::location;
use crate::log::{LogExt, warn};
use crate::smtp::{Smtp, send_smtp_messages};
@@ -211,19 +211,25 @@ impl SchedulerState {
/// Indicate that the network likely has come back.
pub(crate) async fn maybe_network(&self) {
let inner = self.inner.read().await;
let inboxes = match *inner {
let (inboxes, oboxes) = match *inner {
InnerSchedulerState::Started(ref scheduler) => {
scheduler.maybe_network();
scheduler
let inboxes = scheduler
.inboxes
.iter()
.map(|b| b.conn_state.state.connectivity.clone())
.collect::<Vec<_>>()
.collect::<Vec<_>>();
let oboxes = scheduler
.oboxes
.iter()
.map(|b| b.conn_state.state.connectivity.clone())
.collect::<Vec<_>>();
(inboxes, oboxes)
}
_ => return,
};
drop(inner);
connectivity::idle_interrupted(inboxes);
connectivity::idle_interrupted(inboxes, oboxes);
}
/// Indicate that the network likely is lost.
@@ -250,16 +256,6 @@ impl SchedulerState {
}
}
pub(crate) async fn clear_all_relay_storage(&self) -> Result<()> {
let inner = self.inner.read().await;
if let InnerSchedulerState::Started(ref scheduler) = *inner {
scheduler.clear_all_relay_storage();
Ok(())
} else {
bail!("IO is not started");
}
}
pub(crate) async fn interrupt_smtp(&self) {
let inner = self.inner.read().await;
if let InnerSchedulerState::Started(ref scheduler) = *inner {
@@ -322,10 +318,7 @@ impl Drop for IoPausedGuard {
struct SchedBox {
/// Address at the used chatmail/email relay
addr: String,
/// Folder name
folder: String,
meaning: FolderMeaning,
conn_state: ImapConnectionState,
/// IMAP loop task handle.
@@ -337,6 +330,8 @@ struct SchedBox {
pub(crate) struct Scheduler {
/// Inboxes, one per transport.
inboxes: Vec<SchedBox>,
/// Optional boxes -- mvbox.
oboxes: Vec<SchedBox>,
smtp: SmtpConnectionState,
smtp_handle: task::JoinHandle<()>,
ephemeral_handle: task::JoinHandle<()>,
@@ -358,7 +353,6 @@ async fn inbox_loop(
let ImapConnectionHandlers {
mut connection,
stop_token,
clear_storage_request_receiver,
} = inbox_handlers;
let transport_id = connection.transport_id();
@@ -397,14 +391,7 @@ async fn inbox_loop(
}
};
match inbox_fetch_idle(
&ctx,
&mut connection,
session,
&clear_storage_request_receiver,
)
.await
{
match inbox_fetch_idle(&ctx, &mut connection, session).await {
Err(err) => warn!(
ctx,
"Transport {transport_id}: Failed inbox fetch_idle: {err:#}."
@@ -425,30 +412,42 @@ async fn inbox_loop(
.await;
}
async fn inbox_fetch_idle(
/// Convert folder meaning
/// used internally by [fetch_idle] and [Context::background_fetch].
///
/// Returns folder configuration key and folder name
/// if such folder is configured, `Ok(None)` otherwise.
pub async fn convert_folder_meaning(
ctx: &Context,
imap: &mut Imap,
mut session: Session,
clear_storage_request_receiver: &Receiver<()>,
) -> Result<Session> {
folder_meaning: FolderMeaning,
) -> Result<Option<(Config, String)>> {
let folder_config = match folder_meaning.to_config() {
Some(c) => c,
None => {
// Such folder cannot be configured,
// e.g. a `FolderMeaning::Spam` folder.
return Ok(None);
}
};
let folder = ctx
.get_config(folder_config)
.await
.with_context(|| format!("Failed to retrieve {folder_config} folder"))?;
if let Some(watch_folder) = folder {
Ok(Some((folder_config, watch_folder)))
} else {
Ok(None)
}
}
async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session) -> Result<Session> {
let transport_id = session.transport_id();
// Clear IMAP storage on request.
//
// Only doing this for chatmail relays to avoid
// accidentally deleting all emails in a shared mailbox.
let should_clear_imap_storage =
clear_storage_request_receiver.try_recv().is_ok() && session.is_chatmail();
if should_clear_imap_storage {
info!(ctx, "Transport {transport_id}: Clearing IMAP storage.");
session.delete_all_messages(ctx, &imap.folder).await?;
}
// Update quota no more than once a minute.
//
// Always update if we just cleared IMAP storage.
if (ctx.quota_needs_update(session.transport_id(), 60).await || should_clear_imap_storage)
&& let Err(err) = ctx.update_recent_quota(&mut session, &imap.folder).await
if ctx.quota_needs_update(session.transport_id(), 60).await
&& let Err(err) = ctx.update_recent_quota(&mut session).await
{
warn!(
ctx,
@@ -495,7 +494,7 @@ async fn inbox_fetch_idle(
.await
.context("Failed to register push token")?;
let session = fetch_idle(ctx, imap, session).await?;
let session = fetch_idle(ctx, imap, session, FolderMeaning::Inbox).await?;
Ok(session)
}
@@ -504,19 +503,34 @@ async fn inbox_fetch_idle(
/// This function performs all IMAP operations on a single folder, selecting it if necessary and
/// handling all the errors. In case of an error, an error is returned and connection is dropped,
/// otherwise connection is returned.
async fn fetch_idle(ctx: &Context, connection: &mut Imap, mut session: Session) -> Result<Session> {
async fn fetch_idle(
ctx: &Context,
connection: &mut Imap,
mut session: Session,
folder_meaning: FolderMeaning,
) -> Result<Session> {
let transport_id = session.transport_id();
let watch_folder = connection.folder.clone();
let Some((folder_config, watch_folder)) = convert_folder_meaning(ctx, folder_meaning).await?
else {
// The folder is not configured.
// For example, this happens if the server does not have Sent folder
// but watching Sent folder is enabled.
connection.connectivity.set_not_configured(ctx);
connection.idle_interrupt_receiver.recv().await.ok();
bail!("Cannot fetch folder {folder_meaning} because it is not configured");
};
session
.store_seen_flags_on_imap(ctx)
.await
.context("store_seen_flags_on_imap")?;
if folder_config == Config::ConfiguredInboxFolder {
session
.store_seen_flags_on_imap(ctx)
.await
.context("store_seen_flags_on_imap")?;
}
// Fetch the watched folder.
connection
.fetch_move_delete(ctx, &mut session, &watch_folder)
.fetch_move_delete(ctx, &mut session, &watch_folder, folder_meaning)
.await
.context("fetch_move_delete")?;
@@ -550,7 +564,7 @@ async fn fetch_idle(ctx: &Context, connection: &mut Imap, mut session: Session)
ctx,
"Transport {transport_id}: IMAP session does not support IDLE, going to fake idle."
);
connection.fake_idle(ctx, &watch_folder).await?;
connection.fake_idle(ctx, watch_folder).await?;
return Ok(session);
}
@@ -565,7 +579,7 @@ async fn fetch_idle(ctx: &Context, connection: &mut Imap, mut session: Session)
ctx,
"Transport {transport_id}: IMAP IDLE is disabled, going to fake idle."
);
connection.fake_idle(ctx, &watch_folder).await?;
connection.fake_idle(ctx, watch_folder).await?;
return Ok(session);
}
@@ -581,6 +595,69 @@ async fn fetch_idle(ctx: &Context, connection: &mut Imap, mut session: Session)
Ok(session)
}
/// Simplified IMAP loop to watch non-inbox folders.
async fn simple_imap_loop(
ctx: Context,
started: oneshot::Sender<()>,
inbox_handlers: ImapConnectionHandlers,
folder_meaning: FolderMeaning,
) {
use futures::future::FutureExt;
info!(ctx, "Starting simple loop for {folder_meaning}.");
let ImapConnectionHandlers {
mut connection,
stop_token,
} = inbox_handlers;
let ctx1 = ctx.clone();
let fut = async move {
let ctx = ctx1;
if let Err(()) = started.send(()) {
warn!(
ctx,
"Simple imap loop for {folder_meaning}, missing started receiver."
);
return;
}
let mut old_session: Option<Session> = None;
loop {
let session = if let Some(session) = old_session.take() {
session
} else {
info!(ctx, "Preparing new IMAP session for {folder_meaning}.");
match connection.prepare(&ctx).await {
Err(err) => {
warn!(
ctx,
"Failed to prepare {folder_meaning} connection: {err:#}."
);
continue;
}
Ok(session) => session,
}
};
match fetch_idle(&ctx, &mut connection, session, folder_meaning).await {
Err(err) => warn!(ctx, "Failed fetch_idle: {err:#}"),
Ok(session) => {
old_session = Some(session);
}
}
}
};
stop_token
.cancelled()
.map(|_| {
info!(ctx, "Shutting down IMAP loop for {folder_meaning}.");
})
.race(fut)
.await;
}
async fn smtp_loop(
ctx: Context,
started: oneshot::Sender<()>,
@@ -631,7 +708,7 @@ async fn smtp_loop(
info!(ctx, "SMTP fake idle started.");
match &connection.last_send_error {
None => connection.connectivity.set_idle(&ctx),
Some(err) => connection.connectivity.set_err(&ctx, err.clone()),
Some(err) => connection.connectivity.set_err(&ctx, err),
}
// If send_smtp_messages() failed, we set a timeout for the fake-idle so that
@@ -683,6 +760,7 @@ impl Scheduler {
let (location_interrupt_send, location_interrupt_recv) = channel::bounded(1);
let mut inboxes = Vec::new();
let mut oboxes = Vec::new();
let mut start_recvs = Vec::new();
for (transport_id, configured_login_param) in ConfiguredLoginParam::load_all(ctx).await? {
@@ -694,17 +772,30 @@ impl Scheduler {
task::spawn(inbox_loop(ctx, inbox_start_send, inbox_handlers))
};
let addr = configured_login_param.addr.clone();
let folder = configured_login_param
.imap_folder
.unwrap_or_else(|| "INBOX".to_string());
let inbox = SchedBox {
addr: addr.clone(),
folder,
meaning: FolderMeaning::Inbox,
conn_state,
handle,
};
inboxes.push(inbox);
start_recvs.push(inbox_start_recv);
if ctx.should_watch_mvbox().await? {
let (conn_state, handlers) =
ImapConnectionState::new(ctx, transport_id, configured_login_param).await?;
let (start_send, start_recv) = oneshot::channel();
let ctx = ctx.clone();
let meaning = FolderMeaning::Mvbox;
let handle = task::spawn(simple_imap_loop(ctx, start_send, handlers, meaning));
oboxes.push(SchedBox {
addr,
meaning,
conn_state,
handle,
});
start_recvs.push(start_recv);
}
}
let smtp_handle = {
@@ -731,6 +822,7 @@ impl Scheduler {
let res = Self {
inboxes,
oboxes,
smtp,
smtp_handle,
ephemeral_handle,
@@ -750,7 +842,7 @@ impl Scheduler {
}
fn boxes(&self) -> impl Iterator<Item = &SchedBox> {
self.inboxes.iter()
self.inboxes.iter().chain(self.oboxes.iter())
}
fn maybe_network(&self) {
@@ -773,12 +865,6 @@ impl Scheduler {
}
}
fn clear_all_relay_storage(&self) {
for b in &self.inboxes {
b.conn_state.clear_relay_storage();
}
}
fn interrupt_smtp(&self) {
self.smtp.interrupt();
}
@@ -810,7 +896,7 @@ impl Scheduler {
let timeout_duration = std::time::Duration::from_secs(30);
let tracker = TaskTracker::new();
for b in self.inboxes {
for b in self.inboxes.into_iter().chain(self.oboxes) {
let context = context.clone();
tracker.spawn(async move {
tokio::time::timeout(timeout_duration, b.handle)
@@ -912,13 +998,6 @@ struct SmtpConnectionHandlers {
#[derive(Debug)]
pub(crate) struct ImapConnectionState {
state: ConnectionState,
/// Channel to request clearing the folder.
///
/// IMAP loop receiving this should clear the folder
/// on the next iteration if IMAP server is a chatmail relay
/// and otherwise ignore the request.
clear_storage_request_sender: Sender<()>,
}
impl ImapConnectionState {
@@ -930,13 +1009,11 @@ impl ImapConnectionState {
) -> Result<(Self, ImapConnectionHandlers)> {
let stop_token = CancellationToken::new();
let (idle_interrupt_sender, idle_interrupt_receiver) = channel::bounded(1);
let (clear_storage_request_sender, clear_storage_request_receiver) = channel::bounded(1);
let handlers = ImapConnectionHandlers {
connection: Imap::new(context, transport_id, login_param, idle_interrupt_receiver)
.await?,
stop_token: stop_token.clone(),
clear_storage_request_receiver,
};
let state = ConnectionState {
@@ -945,10 +1022,7 @@ impl ImapConnectionState {
connectivity: handlers.connection.connectivity.clone(),
};
let conn = ImapConnectionState {
state,
clear_storage_request_sender,
};
let conn = ImapConnectionState { state };
Ok((conn, handlers))
}
@@ -962,19 +1036,10 @@ impl ImapConnectionState {
fn stop(&self) {
self.state.stop();
}
/// Requests clearing relay storage and interrupts the inbox.
fn clear_relay_storage(&self) {
self.clear_storage_request_sender.try_send(()).ok();
self.state.interrupt();
}
}
#[derive(Debug)]
struct ImapConnectionHandlers {
connection: Imap,
stop_token: CancellationToken,
/// Channel receiver to get requests to clear IMAP storage.
pub(crate) clear_storage_request_receiver: Receiver<()>,
}

View File

@@ -5,10 +5,11 @@ use std::{iter::once, ops::Deref, sync::Arc};
use anyhow::Result;
use humansize::{BINARY, format_size};
use crate::context::Context;
use crate::events::EventType;
use crate::imap::{FolderMeaning, get_watched_folder_configs};
use crate::quota::{QUOTA_ERROR_THRESHOLD_PERCENTAGE, QUOTA_WARN_THRESHOLD_PERCENTAGE};
use crate::stock_str;
use crate::{context::Context, log::LogExt};
use super::InnerSchedulerState;
@@ -66,33 +67,40 @@ enum DetailedConnectivity {
/// Connection is established and is idle.
Idle,
/// The folder was configured not to be watched or configured_*_folder is not set
NotConfigured,
}
impl DetailedConnectivity {
fn to_basic(&self) -> Connectivity {
fn to_basic(&self) -> Option<Connectivity> {
match self {
DetailedConnectivity::Error(_) => Connectivity::NotConnected,
DetailedConnectivity::Uninitialized => Connectivity::NotConnected,
DetailedConnectivity::Connecting => Connectivity::Connecting,
DetailedConnectivity::Working => Connectivity::Working,
DetailedConnectivity::InterruptingIdle => Connectivity::Working,
DetailedConnectivity::Error(_) => Some(Connectivity::NotConnected),
DetailedConnectivity::Uninitialized => Some(Connectivity::NotConnected),
DetailedConnectivity::Connecting => Some(Connectivity::Connecting),
DetailedConnectivity::Working => Some(Connectivity::Working),
DetailedConnectivity::InterruptingIdle => Some(Connectivity::Working),
// At this point IMAP has just connected,
// but does not know yet if there are messages to download.
// We still convert this to Working state
// so user can see "Updating..." and not "Connected"
// which is reserved for idle state.
DetailedConnectivity::Preparing => Connectivity::Working,
DetailedConnectivity::Preparing => Some(Connectivity::Working),
DetailedConnectivity::Idle => Connectivity::Connected,
// Just don't return a connectivity, probably the folder is configured not to be
// watched, so we are not interested in it.
DetailedConnectivity::NotConfigured => None,
DetailedConnectivity::Idle => Some(Connectivity::Connected),
}
}
fn to_icon(&self) -> String {
match self {
DetailedConnectivity::Error(_) | DetailedConnectivity::Uninitialized => {
"<span class=\"red dot\"></span>".to_string()
}
DetailedConnectivity::Error(_)
| DetailedConnectivity::Uninitialized
| DetailedConnectivity::NotConfigured => "<span class=\"red dot\"></span>".to_string(),
DetailedConnectivity::Connecting => "<span class=\"yellow dot\"></span>".to_string(),
DetailedConnectivity::Preparing
| DetailedConnectivity::Working
@@ -112,6 +120,7 @@ impl DetailedConnectivity {
DetailedConnectivity::InterruptingIdle | DetailedConnectivity::Idle => {
stock_str::connected(context)
}
DetailedConnectivity::NotConfigured => "Not configured".to_string(),
}
}
@@ -130,6 +139,7 @@ impl DetailedConnectivity {
DetailedConnectivity::InterruptingIdle
| DetailedConnectivity::Preparing
| DetailedConnectivity::Idle => stock_str::last_msg_sent_successfully(context),
DetailedConnectivity::NotConfigured => "Not configured".to_string(),
}
}
@@ -141,6 +151,7 @@ impl DetailedConnectivity {
DetailedConnectivity::Working => false,
DetailedConnectivity::InterruptingIdle => false,
DetailedConnectivity::Preparing => false, // Just connected, there may still be work to do.
DetailedConnectivity::NotConfigured => true,
DetailedConnectivity::Idle => true,
}
}
@@ -157,8 +168,8 @@ impl ConnectivityStore {
context.emit_event(EventType::ConnectivityChanged);
}
pub(crate) fn set_err(&self, context: &Context, e: String) {
self.set(context, DetailedConnectivity::Error(e));
pub(crate) fn set_err(&self, context: &Context, e: impl ToString) {
self.set(context, DetailedConnectivity::Error(e.to_string()));
}
pub(crate) fn set_connecting(&self, context: &Context) {
self.set(context, DetailedConnectivity::Connecting);
@@ -169,6 +180,9 @@ impl ConnectivityStore {
pub(crate) fn set_preparing(&self, context: &Context) {
self.set(context, DetailedConnectivity::Preparing);
}
pub(crate) fn set_not_configured(&self, context: &Context) {
self.set(context, DetailedConnectivity::NotConfigured);
}
pub(crate) fn set_idle(&self, context: &Context) {
self.set(context, DetailedConnectivity::Idle);
}
@@ -176,7 +190,7 @@ impl ConnectivityStore {
fn get_detailed(&self) -> DetailedConnectivity {
self.0.lock().deref().clone()
}
fn get_basic(&self) -> Connectivity {
fn get_basic(&self) -> Option<Connectivity> {
self.0.lock().to_basic()
}
fn get_all_work_done(&self) -> bool {
@@ -187,14 +201,27 @@ impl ConnectivityStore {
/// Set all folder states to InterruptingIdle in case they were `Idle` before.
/// Called during `dc_maybe_network()` to make sure that `all_work_done()`
/// returns false immediately after `dc_maybe_network()`.
pub(crate) fn idle_interrupted(inboxes: Vec<ConnectivityStore>) {
pub(crate) fn idle_interrupted(inboxes: Vec<ConnectivityStore>, oboxes: Vec<ConnectivityStore>) {
for inbox in inboxes {
let mut connectivity_lock = inbox.0.lock();
if *connectivity_lock == DetailedConnectivity::Idle {
// For the inbox, we also have to set the connectivity to InterruptingIdle if it was
// NotConfigured before: If all folders are NotConfigured, dc_get_connectivity()
// returns Connected. But after dc_maybe_network(), dc_get_connectivity() must not
// return Connected until DC is completely done with fetching folders; this also
// includes scan_folders() which happens on the inbox thread.
if *connectivity_lock == DetailedConnectivity::Idle
|| *connectivity_lock == DetailedConnectivity::NotConfigured
{
*connectivity_lock = DetailedConnectivity::InterruptingIdle;
}
}
for state in oboxes {
let mut connectivity_lock = state.0.lock();
if *connectivity_lock == DetailedConnectivity::Idle {
*connectivity_lock = DetailedConnectivity::InterruptingIdle;
}
}
// No need to send ConnectivityChanged, the user-facing connectivity doesn't change because
// of what we do here.
}
@@ -207,7 +234,9 @@ pub(crate) fn maybe_network_lost(context: &Context, stores: Vec<ConnectivityStor
let mut connectivity_lock = store.0.lock();
if !matches!(
*connectivity_lock,
DetailedConnectivity::Uninitialized | DetailedConnectivity::Error(_)
DetailedConnectivity::Uninitialized
| DetailedConnectivity::Error(_)
| DetailedConnectivity::NotConfigured,
) {
*connectivity_lock = DetailedConnectivity::Error("Connection lost".to_string());
}
@@ -244,8 +273,9 @@ impl Context {
let stores = self.connectivities.lock().clone();
let mut connectivities = Vec::new();
for s in stores {
let connectivity = s.get_basic();
connectivities.push(connectivity);
if let Some(connectivity) = s.get_basic() {
connectivities.push(connectivity);
}
}
connectivities
.into_iter()
@@ -356,7 +386,7 @@ impl Context {
.map(|b| {
(
b.addr.clone(),
b.folder.clone(),
b.meaning,
b.conn_state.state.connectivity.clone(),
)
})
@@ -381,6 +411,7 @@ impl Context {
// [======67%===== ]
// =============================================================================================
let watched_folders = get_watched_folder_configs(self).await?;
let incoming_messages = stock_str::incoming_messages(self);
ret += &format!("<h3>{incoming_messages}</h3><ul>");
@@ -402,14 +433,41 @@ impl Context {
let folders = folders_states
.iter()
.filter(|(folder_addr, ..)| *folder_addr == transport_addr);
for (_addr, _folder, state) in folders {
let detailed = &state.get_detailed();
ret += &*detailed.to_icon();
ret += " <b>";
ret += &*domain_escaped;
ret += ":</b> ";
ret += &*escaper::encode_minimal(&detailed.to_string_imap(self));
ret += "<br />";
for (_addr, folder, state) in folders {
let mut folder_added = false;
if let Some(config) = folder.to_config().filter(|c| watched_folders.contains(c)) {
let f = self.get_config(config).await.log_err(self).ok().flatten();
if let Some(foldername) = f {
let detailed = &state.get_detailed();
ret += &*detailed.to_icon();
ret += " <b>";
if folder == &FolderMeaning::Inbox {
ret += &*domain_escaped;
} else {
ret += &*escaper::encode_minimal(&foldername);
}
ret += ":</b> ";
ret += &*escaper::encode_minimal(&detailed.to_string_imap(self));
ret += "<br />";
folder_added = true;
}
}
if !folder_added && folder == &FolderMeaning::Inbox {
let detailed = &state.get_detailed();
if let DetailedConnectivity::Error(_) = detailed {
// On the inbox thread, we also do some other things like scan_folders and run jobs
// so, maybe, the inbox is not watched, but something else went wrong
ret += &*detailed.to_icon();
ret += " ";
ret += &*escaper::encode_minimal(&detailed.to_string_imap(self));
ret += "<br />";
}
}
}
let Some(quota) = quota.get(&transport_id) else {
@@ -418,7 +476,8 @@ impl Context {
};
match &quota.recent {
Err(e) => {
ret += &escaper::encode_minimal(&e.to_string());
// TODO translate "Quota".
ret += &format!("Quota: {}", &*escaper::encode_minimal(&e.to_string()));
}
Ok(quota) => {
if quota.is_empty() {

View File

@@ -9,6 +9,7 @@ use crate::tools::IsNoneOrEmpty;
/// This escapes a bit more than actually needed by delta (e.g. also lines as "-- footer"),
/// but for non-delta-compatibility, that seems to be better.
/// (to be only compatible with delta, only "[\r\n|\n]-- {0,2}[\r\n|\n]" needs to be replaced)
#[expect(clippy::arithmetic_side_effects)]
pub fn escape_message_footer_marks(text: &str) -> String {
if let Some(text) = text.strip_prefix("--") {
"-\u{200B}-".to_string() + &text.replace("\n--", "\n-\u{200B}-")

View File

@@ -11,12 +11,11 @@ use crate::log::warn;
use crate::net::dns::{lookup_host_with_cache, update_connect_timestamp};
use crate::net::proxy::ProxyConfig;
use crate::net::session::SessionBufStream;
use crate::net::tls::{SpkiHashStore, TlsSessionStore, wrap_tls};
use crate::net::tls::{TlsSessionStore, wrap_tls};
use crate::net::{
connect_tcp_inner, connect_tls_inner, run_connection_attempts, update_connection_history,
};
use crate::oauth2::get_oauth2_access_token;
use crate::sql::Sql;
use crate::tools::time;
use crate::transport::ConnectionCandidate;
use crate::transport::ConnectionSecurity;
@@ -112,26 +111,10 @@ async fn connection_attempt(
);
let res = match security {
ConnectionSecurity::Tls => {
connect_secure(
resolved_addr,
host,
strict_tls,
&context.tls_session_store,
&context.spki_hash_store,
&context.sql,
)
.await
connect_secure(resolved_addr, host, strict_tls, &context.tls_session_store).await
}
ConnectionSecurity::Starttls => {
connect_starttls(
resolved_addr,
host,
strict_tls,
&context.tls_session_store,
&context.spki_hash_store,
&context.sql,
)
.await
connect_starttls(resolved_addr, host, strict_tls, &context.tls_session_store).await
}
ConnectionSecurity::Plain => connect_insecure(resolved_addr).await,
};
@@ -257,8 +240,6 @@ async fn connect_secure_proxy(
alpn(port),
proxy_stream,
&context.tls_session_store,
&context.spki_hash_store,
&context.sql,
)
.await?;
let mut buffered_stream = BufStream::new(tls_stream);
@@ -292,8 +273,6 @@ async fn connect_starttls_proxy(
"",
tcp_stream,
&context.tls_session_store,
&context.spki_hash_store,
&context.sql,
)
.await
.context("STARTTLS upgrade failed")?;
@@ -320,8 +299,6 @@ async fn connect_secure(
hostname: &str,
strict_tls: bool,
tls_session_store: &TlsSessionStore,
spki_hash_store: &SpkiHashStore,
sql: &Sql,
) -> Result<Box<dyn SessionBufStream>> {
let tls_stream = connect_tls_inner(
addr,
@@ -329,8 +306,6 @@ async fn connect_secure(
strict_tls,
alpn(addr.port()),
tls_session_store,
spki_hash_store,
sql,
)
.await?;
let mut buffered_stream = BufStream::new(tls_stream);
@@ -344,8 +319,6 @@ async fn connect_starttls(
host: &str,
strict_tls: bool,
tls_session_store: &TlsSessionStore,
spki_hash_store: &SpkiHashStore,
sql: &Sql,
) -> Result<Box<dyn SessionBufStream>> {
let use_sni = false;
let tcp_stream = connect_tcp_inner(addr).await?;
@@ -363,8 +336,6 @@ async fn connect_starttls(
"",
tcp_stream,
tls_session_store,
spki_hash_store,
sql,
)
.await
.context("STARTTLS upgrade failed")?;

View File

@@ -9,6 +9,7 @@ use rusqlite::{Connection, OpenFlags, Row, config::DbConfig, types::ValueRef};
use tokio::sync::RwLock;
use crate::blob::BlobObject;
use crate::chat::add_device_msg;
use crate::config::Config;
use crate::constants::DC_CHAT_ID_TRASH;
use crate::context::Context;
@@ -17,11 +18,13 @@ use crate::ephemeral::start_ephemeral_timers;
use crate::imex::BLOBS_BACKUP_NAME;
use crate::location::delete_orphaned_poi_locations;
use crate::log::{LogExt, warn};
use crate::message::Message;
use crate::message::MsgId;
use crate::net::dns::prune_dns_cache;
use crate::net::http::http_cache_cleanup;
use crate::net::prune_connection_history;
use crate::param::{Param, Params};
use crate::stock_str;
use crate::tools::{SystemTime, delete_file, time};
/// Extension to [`rusqlite::ToSql`] trait
@@ -827,6 +830,12 @@ pub async fn housekeeping(context: &Context) -> Result<()> {
);
}
maybe_add_mvbox_move_deprecation_message(context)
.await
.context("maybe_add_mvbox_move_deprecation_message")
.log_err(context)
.ok();
if let Err(err) = incremental_vacuum(context).await {
warn!(context, "Failed to run incremental vacuum: {err:#}.");
}
@@ -874,14 +883,6 @@ pub async fn housekeeping(context: &Context) -> Result<()> {
.log_err(context)
.ok();
context
.spki_hash_store
.cleanup(&context.sql)
.await
.context("Failed to prune SPKI store")
.log_err(context)
.ok();
// Cleanup `imap` and `imap_sync` entries for deleted transports.
//
// Transports may be deleted directly or via sync messages,
@@ -912,6 +913,18 @@ pub async fn housekeeping(context: &Context) -> Result<()> {
Ok(())
}
/// Adds device message about `mvbox_move` config deprecation
/// if the user has it enabled.
async fn maybe_add_mvbox_move_deprecation_message(context: &Context) -> Result<()> {
if !context.get_config_bool(Config::OnlyFetchMvbox).await?
&& context.get_config_bool(Config::MvboxMove).await?
{
let mut msg = Message::new_text(stock_str::mvbox_move_deprecation(context));
add_device_msg(context, Some("mvbox_move_deprecation"), Some(&mut msg)).await?;
}
Ok(())
}
/// Get the value of a column `idx` of the `row` as `Vec<u8>`.
pub fn row_get_vec(row: &Row, idx: usize) -> rusqlite::Result<Vec<u8>> {
row.get(idx).or_else(|err| match row.get_ref(idx)? {

View File

@@ -1919,7 +1919,7 @@ CREATE INDEX gossip_timestamp_index ON gossip_timestamp (chat_id, fingerprint);
inc_and_check(&mut migration_version, 131)?;
if dbversion < migration_version {
let entered_param = EnteredLoginParam::load_legacy(context).await?;
let entered_param = EnteredLoginParam::load(context).await?;
let configured_param = ConfiguredLoginParam::load_legacy(context).await?;
sql.execute_migration_transaction(
@@ -2316,66 +2316,6 @@ ALTER TABLE contacts ADD COLUMN name_normalized TEXT;
.await?;
}
inc_and_check(&mut migration_version, 150)?;
if dbversion < migration_version {
sql.execute_migration_transaction(
|transaction| {
let only_fetch_mvbox = transaction
.query_row(
"SELECT value FROM config WHERE keyname='only_fetch_mvbox'",
(),
|row| {
let value: String = row.get(0)?;
Ok(value)
},
)
.optional()?
.as_deref()
== Some("1");
if only_fetch_mvbox {
let mvbox_folder = transaction
.query_row(
"SELECT value FROM config WHERE keyname='configured_mvbox_folder'",
(),
|row| {
let value: String = row.get(0)?;
Ok(value)
},
)
.optional()?
.unwrap_or_else(|| "DeltaChat".to_string());
transaction.execute(
"UPDATE transports
SET entered_param=json_set(entered_param, '$.imap.folder', ?1),
configured_param=json_set(configured_param', '$.imap_folder', ?1)",
(mvbox_folder,),
)?;
}
Ok(())
},
migration_version,
)
.await?;
}
inc_and_check(&mut migration_version, 151)?;
if dbversion < migration_version {
sql.execute_migration(
"CREATE TABLE tls_spki (
host TEXT NOT NULL UNIQUE,
spki_hash TEXT NOT NULL, -- base64 of SPKI SHA-256 hash
timestamp INTEGER NOT NULL -- timestamp of the last time we have seen this key
) STRICT;
-- Index on host column is created implicitly because of UNIQUE constraint.
CREATE INDEX tls_spki_index_timestamp ON tls_spki (timestamp);
",
migration_version,
)
.await?;
}
let new_version = sql
.get_raw_config_int(VERSION_CFG)
.await?

View File

@@ -413,6 +413,11 @@ https://delta.chat/donate"))]
#[strum(props(fallback = "Messages in this chat use classic email and are not encrypted."))]
ChatUnencryptedExplanation = 230,
#[strum(props(
fallback = "You are using the legacy option \"Settings → Advanced → Move automatically to DeltaChat Folder\".\n\nThis option will be removed in a few weeks and you should disable it already today.\n\nIf having chat messages mixed into your inbox is a problem, see https://delta.chat/legacy-move"
))]
MvboxMoveDeprecation = 231,
#[strum(props(fallback = "Outgoing audio call"))]
OutgoingAudioCall = 232,
@@ -1220,6 +1225,11 @@ pub(crate) fn chat_unencrypted_explanation(context: &Context) -> String {
translated(context, StockMessage::ChatUnencryptedExplanation)
}
/// Stock string: `You are using the legacy option "Move automatically to DeltaChat Folder`…
pub(crate) fn mvbox_move_deprecation(context: &Context) -> String {
translated(context, StockMessage::MvboxMoveDeprecation)
}
impl Viewtype {
/// returns Localized name for message viewtype
pub fn to_locale_string(&self, context: &Context) -> String {

View File

@@ -275,17 +275,16 @@ impl TestContextManager {
let chat_id = join_securejoin(&joiner.ctx, qr).await.unwrap();
for _ in 0..2 {
loop {
let mut something_sent = false;
let rev_order = false;
if let Some(sent) = joiner.pop_sent_msg_ex(rev_order, Duration::ZERO).await {
if let Some(sent) = joiner.pop_sent_msg_opt(Duration::ZERO).await {
for inviter in inviters {
inviter.recv_msg_opt(&sent).await;
}
something_sent = true;
}
for inviter in inviters {
if let Some(sent) = inviter.pop_sent_msg_ex(rev_order, Duration::ZERO).await {
if let Some(sent) = inviter.pop_sent_msg_opt(Duration::ZERO).await {
joiner.recv_msg_opt(&sent).await;
something_sent = true;
}
@@ -568,6 +567,7 @@ impl TestContext {
.unwrap();
ctx.set_config(Config::BccSelf, Some("1")).await.unwrap();
ctx.set_config(Config::SyncMsgs, Some("0")).await.unwrap();
ctx.set_config(Config::MvboxMove, Some("0")).await.unwrap();
Self {
ctx,
@@ -624,35 +624,25 @@ impl TestContext {
}
pub async fn pop_sent_msg_opt(&self, timeout: Duration) -> Option<SentMessage<'_>> {
let rev_order = true;
self.pop_sent_msg_ex(rev_order, timeout).await
}
pub async fn pop_sent_msg_ex(
&self,
rev_order: bool,
timeout: Duration,
) -> Option<SentMessage<'_>> {
let start = Instant::now();
let mut query = "
SELECT id, msg_id, mime, recipients
FROM smtp
ORDER BY id"
.to_string();
if rev_order {
query += " DESC";
}
let (rowid, msg_id, payload, recipients) = loop {
let row = self
.ctx
.sql
.query_row_optional(&query, (), |row| {
let rowid: i64 = row.get(0)?;
let msg_id: MsgId = row.get(1)?;
let mime: String = row.get(2)?;
let recipients: String = row.get(3)?;
Ok((rowid, msg_id, mime, recipients))
})
.query_row_optional(
r#"
SELECT id, msg_id, mime, recipients
FROM smtp
ORDER BY id DESC"#,
(),
|row| {
let rowid: i64 = row.get(0)?;
let msg_id: MsgId = row.get(1)?;
let mime: String = row.get(2)?;
let recipients: String = row.get(3)?;
Ok((rowid, msg_id, mime, recipients))
},
)
.await
.expect("query_row_optional failed");
if let Some(row) = row {
@@ -834,24 +824,17 @@ ORDER BY id"
assert_eq!(received.chat_id, DC_CHAT_ID_TRASH);
}
/// Gets the most recent message ID of a chat.
///
/// Panics on errors or if the most recent message is a marker.
pub async fn get_last_msg_id_in(&self, chat_id: ChatId) -> MsgId {
let msgs = chat::get_chat_msgs(&self.ctx, chat_id).await.unwrap();
if let ChatItem::Message { msg_id } = msgs.last().unwrap() {
*msg_id
} else {
panic!("Wrong item type");
}
}
/// Gets the most recent message of a chat.
///
/// Panics on errors or if the most recent message is a marker.
pub async fn get_last_msg_in(&self, chat_id: ChatId) -> Message {
let msg_id = self.get_last_msg_id_in(chat_id).await;
Message::load_from_db(&self.ctx, msg_id).await.unwrap()
let msgs = chat::get_chat_msgs(&self.ctx, chat_id).await.unwrap();
let msg_id = if let ChatItem::Message { msg_id } = msgs.last().unwrap() {
msg_id
} else {
panic!("Wrong item type");
};
Message::load_from_db(&self.ctx, *msg_id).await.unwrap()
}
/// Gets the most recent message over all chats.

View File

@@ -118,13 +118,7 @@ async fn test_receive_pre_message() -> Result<()> {
let msg = bob.recv_msg(&pre_message).await;
assert_eq!(msg.download_state, DownloadState::Available);
assert_summary_texts(&msg, bob, "👤 test").await;
let chat_id = msg.chat_id;
assert!(bob.recv_msg_opt(&pre_message).await.is_none());
let msg = Message::load_from_db(bob, msg.id).await?;
assert_eq!(msg.download_state, DownloadState::Available);
assert_summary_texts(&msg, bob, "👤 test").await;
assert_eq!(msg.chat_id, chat_id);
Ok(())
}

View File

@@ -687,6 +687,7 @@ fn extract_address_from_receive_header<'a>(header: &'a str, start: &str) -> Opti
})
}
#[expect(clippy::arithmetic_side_effects)]
pub(crate) fn parse_receive_header(header: &str) -> String {
let header = header.replace(&['\r', '\n'][..], "");
let mut hop_info = String::from("Hop: ");

View File

@@ -19,7 +19,6 @@ use crate::config::Config;
use crate::configure::server_params::{ServerParams, expand_param_vector};
use crate::constants::{DC_LP_AUTH_FLAGS, DC_LP_AUTH_OAUTH2};
use crate::context::Context;
use crate::ensure_and_debug_assert;
use crate::events::EventType;
use crate::login_param::EnteredLoginParam;
use crate::net::load_connection_timestamp;
@@ -164,30 +163,22 @@ pub(crate) struct ConfiguredLoginParam {
/// `From:` address that was used at the time of configuration.
pub addr: String,
/// List of IMAP candidates to try.
pub imap: Vec<ConfiguredServerLoginParam>,
/// Custom IMAP user.
///
/// This overwrites autoconfig from the provider database
/// if non-empty.
// Custom IMAP user.
//
// This overwrites autoconfig from the provider database
// if non-empty.
pub imap_user: String,
pub imap_password: String,
// IMAP folder to watch.
//
// If not stored, should be interpreted as "INBOX".
// If stored, should be a folder name and not empty.
pub imap_folder: Option<String>,
/// List of SMTP candidates to try.
pub smtp: Vec<ConfiguredServerLoginParam>,
/// Custom SMTP user.
///
/// This overwrites autoconfig from the provider database
/// if non-empty.
// Custom SMTP user.
//
// This overwrites autoconfig from the provider database
// if non-empty.
pub smtp_user: String,
pub smtp_password: String,
@@ -208,13 +199,6 @@ pub(crate) struct ConfiguredLoginParam {
pub(crate) struct ConfiguredLoginParamJson {
pub addr: String,
pub imap: Vec<ConfiguredServerLoginParam>,
/// IMAP folder to watch.
///
/// Defaults to "INBOX" if unset.
#[serde(skip_serializing_if = "Option::is_none")]
pub imap_folder: Option<String>,
pub imap_user: String,
pub imap_password: String,
pub smtp: Vec<ConfiguredServerLoginParam>,
@@ -561,7 +545,6 @@ impl ConfiguredLoginParam {
Ok(Some(ConfiguredLoginParam {
addr,
imap,
imap_folder: None,
imap_user: mail_user,
imap_password: mail_pw,
smtp,
@@ -594,18 +577,11 @@ impl ConfiguredLoginParam {
pub(crate) fn from_json(json: &str) -> Result<Self> {
let json: ConfiguredLoginParamJson = serde_json::from_str(json)?;
ensure_and_debug_assert!(
json.imap_folder
.as_ref()
.is_none_or(|folder| !folder.is_empty()),
"Configured watched folder name cannot be empty"
);
let provider = json.provider_id.and_then(|id| get_provider_by_id(&id));
Ok(ConfiguredLoginParam {
addr: json.addr,
imap: json.imap,
imap_folder: json.imap_folder,
imap_user: json.imap_user,
imap_password: json.imap_password,
smtp: json.smtp,
@@ -643,7 +619,6 @@ impl From<ConfiguredLoginParam> for ConfiguredLoginParamJson {
imap: configured_login_param.imap,
imap_user: configured_login_param.imap_user,
imap_password: configured_login_param.imap_password,
imap_folder: configured_login_param.imap_folder,
smtp: configured_login_param.smtp,
smtp_user: configured_login_param.smtp_user,
smtp_password: configured_login_param.smtp_password,
@@ -663,16 +638,9 @@ pub(crate) async fn save_transport(
add_timestamp: i64,
is_published: bool,
) -> Result<bool> {
ensure_and_debug_assert!(
configured
.imap_folder
.as_ref()
.is_none_or(|folder| !folder.is_empty()),
"Configured watched folder name cannot be empty"
);
let addr = addr_normalize(&configured.addr);
let configured_addr = context.get_config(Config::ConfiguredAddr).await?;
let mut modified = context
.sql
.execute(

View File

@@ -34,7 +34,6 @@ async fn test_save_load_login_param() -> Result<()> {
},
user: "alice".to_string(),
}],
imap_folder: None,
imap_user: "".to_string(),
imap_password: "foo".to_string(),
smtp: vec![ConfiguredServerLoginParam {
@@ -118,6 +117,8 @@ async fn test_posteo_alias() -> Result<()> {
t.set_config(Config::ConfiguredSendUser, Some(user)).await?;
t.set_config(Config::ConfiguredSendPw, Some("foobarbaz"))
.await?;
t.set_config(Config::ConfiguredSmtpCertificateChecks, Some("1"))
.await?; // Strict
t.set_config(Config::ConfiguredServerFlags, Some("0"))
.await?;
@@ -141,7 +142,6 @@ async fn test_posteo_alias() -> Result<()> {
user: user.to_string(),
},
],
imap_folder: None,
imap_user: "alice@posteo.de".to_string(),
imap_password: "foobarbaz".to_string(),
smtp: vec![
@@ -205,6 +205,8 @@ async fn test_empty_server_list_legacy() -> Result<()> {
.await?; // Strict
t.set_config(Config::ConfiguredSendPw, Some("foobarbaz"))
.await?;
t.set_config(Config::ConfiguredSmtpCertificateChecks, Some("1"))
.await?; // Strict
t.set_config(Config::ConfiguredServerFlags, Some("0"))
.await?;
@@ -270,7 +272,6 @@ fn dummy_configured_login_param(
},
user: addr.to_string(),
}],
imap_folder: None,
imap_user: addr.to_string(),
imap_password: "foobarbaz".to_string(),
smtp: vec![ConfiguredServerLoginParam {

View File

@@ -111,12 +111,6 @@ pub struct WebxdcInfo {
/// Address to be used for `window.webxdc.selfAddr` in JS land.
pub self_addr: String,
/// Define if the local user is the one who initially shared the webxdc application in the chat.
pub is_app_sender: bool,
/// Define if the app runs in a broadcasting context.
pub is_broadcast: bool,
/// Milliseconds to wait before calling `sendUpdate()` again since the last call.
/// Should be exposed to `window.sendUpdateInterval` in JS land.
pub send_update_interval: usize,
@@ -929,11 +923,6 @@ impl Message {
let internet_access = is_integrated;
let self_addr = self.get_webxdc_self_addr(context).await?;
let is_app_sender = self.from_id == ContactId::SELF;
let chat = Chat::load_from_db(context, self.chat_id)
.await
.with_context(|| "Failed to load chat from the database")?;
let is_broadcast = chat.typ == Chattype::InBroadcast || chat.typ == Chattype::OutBroadcast;
Ok(WebxdcInfo {
name: if let Some(name) = manifest.name {
@@ -972,8 +961,6 @@ impl Message {
request_integration,
internet_access,
self_addr,
is_app_sender,
is_broadcast,
send_update_interval: context.ratelimit.read().await.update_interval(),
send_update_max_size: RECOMMENDED_FILE_SIZE as usize,
})

View File

@@ -12,7 +12,6 @@ use crate::chatlist::Chatlist;
use crate::config::Config;
use crate::ephemeral;
use crate::receive_imf::receive_imf;
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::{E2EE_INFO_MSGS, TestContext, TestContextManager};
use crate::tools::{self, SystemTime};
use crate::{message, sql};
@@ -2196,42 +2195,3 @@ async fn test_self_addr_consistency() -> Result<()> {
assert_eq!(db_msg.get_webxdc_self_addr(alice).await?, self_addr);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_webxdc_info_app_sender() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
// Alice sends webxdc in a group chat
let alice_chat_id = alice.create_group_with_members("Group", &[bob]).await;
let alice_instance = send_webxdc_instance(alice, alice_chat_id).await?;
let sent1 = alice.pop_sent_msg().await;
let alice_info = alice_instance.get_webxdc_info(alice).await?;
assert!(alice_info.is_app_sender);
assert!(!alice_info.is_broadcast);
// Bob receives group webxdc
let bob_instance = bob.recv_msg(&sent1).await;
let bob_info = bob_instance.get_webxdc_info(bob).await?;
assert!(!bob_info.is_app_sender);
assert!(!bob_info.is_broadcast);
// Alice sends webxdc to broadcast channel
let alice_chat_id = create_broadcast(alice, "Broadcast".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
tcm.exec_securejoin_qr(bob, alice, &qr).await;
let alice_instance = send_webxdc_instance(alice, alice_chat_id).await?;
let sent2 = alice.pop_sent_msg().await;
let alice_info = alice_instance.get_webxdc_info(alice).await?;
assert!(alice_info.is_app_sender);
assert!(alice_info.is_broadcast);
// Bob receives broadcast webxdc
let bob_instance = bob.recv_msg(&sent2).await;
let bob_info = bob_instance.get_webxdc_info(bob).await?;
assert!(!bob_info.is_app_sender);
assert!(bob_info.is_broadcast);
Ok(())
}