Compare commits

...
Author SHA1 Message Date
holger krekel 3617b95a9d test: allow to run the test suite against underscore-domain relays
Such relays serve self-signed certificates, and are created e.g. by cmlxc deploys.
2026-07-30 18:02:51 +02:00
holger krekel 30a196c483 test: load test data through the data fixture
Relative paths only worked when running from `deltachat-rpc-client`.
2026-03-01 23:51:44 +01:00
holger krekel 35555ca753 api!: remove getPushState() and core's internal tracking of it.
Since https://github.com/deltachat/deltachat-ios/pull/3224 pushstate is not used
(android, desktop etc. never used it, only ios)
2026-07-30 17:32:54 +02:00
holger krekel 0bb3d88bc6 feat: stop requiring XDELTAPUSH capability for push notifications
register device token if XDELTAPUSH IMAP capability is available (current relay setup provides it)
or if "maxsmtprecipients" IMAP metadata key is available (relays since May 2026 provide it),
allowing chatmail relay setups to drop XDELTAPUSH capability marker with the next release
while retaining push notification support.
2026-07-30 17:32:54 +02:00
link2xt e74ffb6e93 docs: add missing slash to ConnectionSecurity::Starttls doc comment 2026-07-29 23:50:59 +00:00
link2xt bbcfa5e40e chore: disable "large futures" lint again
The lint is reasonable, but without some work
to bring the future sizes way below the limit,
this lint is frequently triggered in unrelated PRs
that just add some local variables to async functions
or extend some structure that is passed on the stack.
2026-07-29 00:47:39 +00:00
24 changed files with 119 additions and 204 deletions
-16
View File
@@ -638,22 +638,6 @@ int dc_get_connectivity (dc_context_t* context);
char* dc_get_connectivity_html (dc_context_t* context);
#define DC_PUSH_NOT_CONNECTED 0
#define DC_PUSH_CONNECTED 2
/**
* Get the current push notification state.
* One of:
* - DC_PUSH_NOT_CONNECTED
* - DC_PUSH_CONNECTED
*
* @memberof dc_context_t
* @param context The context object.
* @return Push notification state.
*/
int dc_get_push_state (dc_context_t* context);
// connect
/**
-10
View File
@@ -397,16 +397,6 @@ pub unsafe extern "C" fn dc_get_connectivity_html(
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dc_get_push_state(context: *const dc_context_t) -> libc::c_int {
if context.is_null() {
eprintln!("ignoring careless call to dc_get_push_state()");
return 0;
}
let ctx = unsafe { &*context };
ctx.push_state() as libc::c_int
}
fn spawn_configure(ctx: Context) {
spawn(async move {
ctx.configure()
-7
View File
@@ -53,7 +53,6 @@ use types::contact::{ContactObject, VcardContact};
use types::events::Event;
use types::http::HttpResponse;
use types::message::{MessageData, MessageObject, MessageReadReceipt};
use types::notify_state::JsonrpcNotifyState;
use types::reactions::JsonrpcReactions;
use types::webxdc::WebxdcMessageInfo;
@@ -328,12 +327,6 @@ impl CommandApi {
}
}
/// Get the current push notification state.
async fn get_push_state(&self, account_id: u32) -> Result<JsonrpcNotifyState> {
let ctx = self.get_context(account_id).await?;
Ok(ctx.push_state().into())
}
/// Get the combined filesize of an account in bytes
async fn get_account_file_size(&self, account_id: u32) -> Result<u64> {
let ctx = self.get_context(account_id).await?;
-2
View File
@@ -8,8 +8,6 @@ pub mod http;
pub mod location;
pub mod login_param;
pub mod message;
pub mod notify_state;
pub mod qr;
pub mod reactions;
pub mod webxdc;
@@ -1,22 +0,0 @@
use deltachat::push::NotifyState;
use serde::Serialize;
use typescript_type_def::TypeDef;
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename = "NotifyState")]
pub enum JsonrpcNotifyState {
/// Not subscribed to push notifications.
NotConnected,
/// Subscribed to push notifications for new messages.
Connected,
}
impl From<NotifyState> for JsonrpcNotifyState {
fn from(state: NotifyState) -> Self {
match state {
NotifyState::NotConnected => Self::NotConnected,
NotifyState::Connected => Self::Connected,
}
}
}
@@ -44,7 +44,6 @@ const constants = data
key.startsWith("DC_CERTCK_") ||
key.startsWith("DC_SOCKET_") ||
key.startsWith("DC_LP_AUTH_") ||
key.startsWith("DC_PUSH_") ||
key.startsWith("DC_TEXT1_") ||
key.startsWith("DC_CHAT_TYPE")
);
@@ -246,13 +246,6 @@ class ProviderStatus(IntEnum):
BROKEN = 3
class PushNotifyState(IntEnum):
"""Push notifications state."""
NOT_CONNECTED = 0
CONNECTED = 2
class ShowEmails(IntEnum):
"""Show emails mode."""
@@ -9,6 +9,7 @@ import platform
import random
import subprocess
import sys
import urllib.parse
from typing import AsyncGenerator, Optional
import pytest
@@ -282,8 +283,15 @@ def alice_and_remote_bob(tmp_path, acfactory, get_core_python_env):
channel = gw.remote_exec(remote_bob_loop)
cm = os.environ.get("CHATMAIL_DOMAIN")
# old cores need "ic=3" to accept
# the self-signed cert of an underscore domain
addr, password = acfactory.get_credentials()
dclogin_qr = f"dclogin://{urllib.parse.quote(addr, safe='@')}?p={urllib.parse.quote(password)}&v=1"
if cm and cm.startswith("_"):
dclogin_qr += "&ic=3"
# trigger getting an online account on bob's side
channel.send((accounts_dir, str(rpc_server_path), cm))
channel.send((accounts_dir, str(rpc_server_path), dclogin_qr))
# meanwhile get a local alice account
alice = acfactory.get_online_account()
@@ -315,10 +323,8 @@ def remote_bob_loop(channel):
import os
from deltachat_rpc_client import DeltaChat, Rpc
from deltachat_rpc_client.pytestplugin import ACFactory
accounts_dir, rpc_server_path, chatmail_domain = channel.receive()
os.environ["CHATMAIL_DOMAIN"] = chatmail_domain
accounts_dir, rpc_server_path, dclogin_qr = channel.receive()
# older core versions don't support specifying rpc_server_path
# so we can't just pass `rpc_server_path` argument to Rpc constructor
@@ -329,8 +335,13 @@ def remote_bob_loop(channel):
with rpc:
dc = DeltaChat(rpc)
channel.send(dc.rpc.get_system_info()["deltachat_core_version"])
acfactory = ACFactory(dc)
bob = acfactory.get_online_account()
# ACFactory would configure from a "dcaccount" QR,
# which old cores cannot use on underscore domains
bob = dc.add_account()
bob.set_config_from_qr(dclogin_qr)
bob.bring_online()
alice_vcard = channel.receive()
[alice_contact] = bob.import_vcard(alice_vcard)
ns = {"bob": bob, "bob_contact_alice": alice_contact}
+5 -1
View File
@@ -37,7 +37,11 @@ class DirectImap:
host = user.rsplit("@")[-1]
pw = self.account.get_config("mail_pw")
self.conn = MailBox(host, port, ssl_context=ssl.create_default_context())
ssl_context = ssl.create_default_context()
if host.startswith("_"):
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
self.conn = MailBox(host, port, ssl_context=ssl_context)
self.conn.login(user, pw)
self.select_folder("INBOX")
@@ -110,7 +110,7 @@ def test_delivery_status_failed(acfactory: ACFactory) -> None:
assert failing_message.get_snapshot().state == const.MessageState.OUT_FAILED
def test_download_on_demand(acfactory: ACFactory) -> None:
def test_download_on_demand(acfactory: ACFactory, data) -> None:
"""
Test if download on demand emits chatlist update events.
This is only needed for last message in chat, but finding that out is too expensive, so it's always emitted
@@ -128,7 +128,7 @@ def test_download_on_demand(acfactory: ACFactory) -> None:
msg.get_snapshot().chat.accept()
bob.get_chat_by_id(chat_id).send_message(
"Hello World, this message is bigger than 5 bytes",
file="../test-data/image/screenshot.jpg",
file=data.get_path("image/screenshot.jpg"),
)
message = alice.wait_for_incoming_msg()
@@ -45,7 +45,7 @@ def test_qr_setup_contact(acfactory, alice_and_remote_bob, version) -> None:
def test_send_and_receive_message(alice_and_remote_bob) -> None:
"""Test other-core Bob profile can send a message to Alice on current core."""
alice, alice_contact_bob, remote_eval = alice_and_remote_bob("2.20.0")
alice, alice_contact_bob, remote_eval = alice_and_remote_bob("2.24.0")
remote_eval("bob_contact_alice.create_chat().send_text('hello')")
@@ -55,7 +55,7 @@ def test_send_and_receive_message(alice_and_remote_bob) -> None:
def test_second_device(acfactory, alice_and_remote_bob) -> None:
"""Test setting up current version as a second device for old version."""
_alice, alice_contact_bob, remote_eval = alice_and_remote_bob("2.20.0")
_alice, alice_contact_bob, remote_eval = alice_and_remote_bob("2.24.0")
remote_eval("locals().setdefault('future', bob._rpc.provide_backup.future(bob.id))")
qr = remote_eval("bob._rpc.get_backup_qr(bob.id)")
@@ -17,6 +17,12 @@ import pytest
from deltachat_rpc_client import EventType
@pytest.fixture(autouse=True)
def _xfail_underscore_domain():
if os.environ.get("CHATMAIL_DOMAIN", "").startswith("_"):
pytest.xfail("iroh does not support underscore domains")
@pytest.fixture
def path_to_webxdc(request):
p = request.path.parent.parent.parent.joinpath("test-data/webxdc/chess.xdc")
@@ -76,7 +76,7 @@ def test_change_address(acfactory) -> None:
assert sender_addr2 == new_alice_addr
def test_download_on_demand(acfactory) -> None:
def test_download_on_demand(acfactory, data) -> None:
alice, bob = acfactory.get_online_accounts(2)
alice.set_config("download_limit", "1")
@@ -87,7 +87,7 @@ def test_download_on_demand(acfactory) -> None:
alice.create_chat(bob)
chat_bob_alice = bob.create_chat(alice)
chat_bob_alice.send_message(file="../test-data/image/screenshot.jpg")
chat_bob_alice.send_message(file=data.get_path("image/screenshot.jpg"))
msg = alice.wait_for_incoming_msg()
snapshot = msg.get_snapshot()
assert snapshot.download_state == DownloadState.AVAILABLE
+6 -3
View File
@@ -105,8 +105,11 @@ def test_lowercase_address(acfactory) -> None:
def test_configure_ip(acfactory) -> None:
addr, password = acfactory.get_credentials()
domain = addr.rsplit("@")[-1]
if domain.startswith("_"):
pytest.skip("Underscore domains accept invalid certificates")
account = acfactory.get_unconfigured_account()
ip_address = socket.gethostbyname(addr.rsplit("@")[-1])
ip_address = socket.gethostbyname(domain)
with pytest.raises(JsonRpcError):
account.add_or_update_transport(
@@ -1407,7 +1410,7 @@ def test_synchronize_member_list_on_group_rejoin(acfactory, log):
assert msg.get_snapshot().chat.num_contacts() == 2
def test_large_message(acfactory) -> None:
def test_large_message(acfactory, data) -> None:
"""
Test sending large message without download limit set,
so it is sent with pre-message but downloaded without user interaction.
@@ -1417,7 +1420,7 @@ def test_large_message(acfactory) -> None:
alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.send_message(
"Hello World, this message is bigger than 5 bytes",
file="../test-data/image/screenshot.jpg",
file=data.get_path("image/screenshot.jpg"),
)
msg = bob.wait_for_incoming_msg()
+4 -4
View File
@@ -1,9 +1,9 @@
def test_webxdc(acfactory) -> None:
def test_webxdc(acfactory, data) -> None:
alice, bob = acfactory.get_online_accounts(2)
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.send_message(text="Let's play chess!", file="../test-data/webxdc/chess.xdc")
alice_chat_bob.send_message(text="Let's play chess!", file=data.get_path("webxdc/chess.xdc"))
event = bob.wait_for_incoming_msg_event()
bob_chat_alice = bob.get_chat_by_id(event.chat_id)
@@ -43,12 +43,12 @@ def test_webxdc(acfactory) -> None:
]
def test_webxdc_insert_lots_of_updates(acfactory) -> None:
def test_webxdc_insert_lots_of_updates(acfactory, data) -> None:
alice, bob = acfactory.get_online_accounts(2)
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
message = alice_chat_bob.send_message(text="Let's play chess!", file="../test-data/webxdc/chess.xdc")
message = alice_chat_bob.send_message(text="Let's play chess!", file=data.get_path("webxdc/chess.xdc"))
for i in range(2000):
message.send_webxdc_status_update({"payload": str(i)}, "description")
-5
View File
@@ -4,7 +4,6 @@ use std::collections::{BTreeMap, HashMap};
use std::ffi::OsString;
use std::ops::Deref;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, OnceLock, Weak};
use std::time::Duration;
@@ -296,9 +295,6 @@ pub struct InnerContext {
/// Push subscriber to store device token.
pub(crate) push_subscriber: PushSubscriber,
/// True if account has subscribed to push notifications via IMAP.
pub(crate) push_subscribed: AtomicBool,
/// TLS session resumption cache.
pub(crate) tls_session_store: TlsSessionStore,
@@ -497,7 +493,6 @@ impl Context {
migration_error: parking_lot::RwLock::new(None),
debug_logging: std::sync::RwLock::new(None),
push_subscriber,
push_subscribed: AtomicBool::new(false),
tls_session_store: TlsSessionStore::new(),
spki_hash_store: SpkiHashStore::new(),
iroh: Arc::new(RwLock::new(None)),
+66 -64
View File
@@ -10,7 +10,6 @@ use std::{
iter::Peekable,
mem::take,
str::FromStr,
sync::atomic::Ordering,
time::{Duration, UNIX_EPOCH},
};
@@ -126,6 +125,11 @@ pub(crate) struct ServerMetadata {
/// Maximum number of recipients for SMTP `RCPT TO:`.
pub max_smtp_rcpt_to: Option<u32>,
/// True if we think the relay supports push notifications.
/// This gates wether we attempt to write an encrypted device token
/// to per-transport IMAP metadata key `/private/devicetoken`.
pub supports_push: bool,
/// ICE servers for WebRTC calls.
pub ice_servers: Vec<UnresolvedIceServer>,
@@ -1411,6 +1415,7 @@ impl Session {
admin,
iroh_relay,
max_smtp_rcpt_to,
supports_push: max_smtp_rcpt_to.is_some() || self.capabilities.has_xdeltapush,
ice_servers,
ice_servers_expiration_timestamp,
},
@@ -1420,44 +1425,43 @@ impl Session {
/// Stores device token into /private/devicetoken IMAP METADATA of the Inbox.
pub(crate) async fn register_token(&mut self, context: &Context) -> Result<()> {
if context.push_subscribed.load(Ordering::Relaxed) {
// `update_metadata` ran before and computed `supports_push`.
if self.push_token_registered
|| !context
.metadata
.read()
.await
.get(&self.transport_id())
.is_some_and(|metadata| metadata.supports_push)
{
return Ok(());
}
let transport_id = self.transport_id();
let Some(device_token) = context.push_subscriber.device_token() else {
return Ok(());
};
if self.can_metadata() && self.can_push() {
info!(
context,
"Transport {transport_id}: Subscribing for push notifications."
);
let transport_id = self.transport_id();
let old_encrypted_device_token =
context.get_config(Config::EncryptedDeviceToken).await?;
info!(
context,
"Transport {transport_id}: Subscribing for push notifications."
);
// Whether we need to update encrypted device token.
let device_token_changed = old_encrypted_device_token.is_none()
|| context.get_config(Config::DeviceToken).await?.as_ref() != Some(&device_token);
let new_encrypted_device_token;
if device_token_changed {
// Reuse the stored ciphertext if the token is unchanged:
// encryption gives a different result each time
// and the token must be sent byte-identical on every attempt
// so the server can deduplicate registrations.
let old_device_token = context.get_config(Config::DeviceToken).await?;
let encrypted_device_token = match context.get_config(Config::EncryptedDeviceToken).await? {
Some(old_encrypted_device_token)
if old_device_token.as_ref() == Some(&device_token) =>
{
old_encrypted_device_token
}
_ => {
let encrypted_device_token = encrypt_device_token(&device_token)
.context("Failed to encrypt device token")?;
// We expect that the server supporting `XDELTAPUSH` capability
// has non-synchronizing literals support as well:
// <https://www.rfc-editor.org/rfc/rfc7888>.
let encrypted_device_token_len = encrypted_device_token.len();
// Store device token saved on the server
// to prevent storing duplicate tokens.
// The server cannot deduplicate on its own
// because encryption gives a different
// result each time.
context
.set_config_internal(Config::DeviceToken, Some(&device_token))
.await?;
@@ -1467,51 +1471,49 @@ impl Session {
Some(&encrypted_device_token),
)
.await?;
if encrypted_device_token_len <= 4096 {
new_encrypted_device_token = Some(encrypted_device_token);
} else {
// If Apple or Google (FCM) gives us a very large token,
// do not even try to give it to IMAP servers.
//
// Limit of 4096 is arbitrarily selected
// to be the same as required by LITERAL- IMAP extension.
//
// Dovecot supports LITERAL+ and non-synchronizing literals
// of any length, but there is no reason for tokens
// to be that large even after OpenPGP encryption.
warn!(context, "Device token is too long for LITERAL-, ignoring.");
new_encrypted_device_token = None;
}
} else {
new_encrypted_device_token = old_encrypted_device_token;
encrypted_device_token
}
};
// Store new encrypted device token on the server
// even if it is the same as the old one.
if let Some(encrypted_device_token) = new_encrypted_device_token {
self.run_command_and_check_ok(&format_setmetadata(
"INBOX",
&encrypted_device_token,
))
.await
.context("SETMETADATA command failed")?;
// If the token cannot be stored we must not retry
// on every IMAP loop iteration / register_token invocation.
self.push_token_registered = true;
context.push_subscribed.store(true, Ordering::Relaxed);
}
// The token is sent as an IMAP quoted string
// (<https://www.rfc-editor.org/rfc/rfc3501#section-4.3>),
// which carries printable ASCII without double quotes or backslashes.
// The encrypted token is `openpgp:` followed by base64
// but let's guard against future changes
// rather than send a malformed or oversized command.
if encrypted_device_token.len() > 4096
|| !encrypted_device_token
.bytes()
.all(|b| b.is_ascii_graphic() && b != b'"' && b != b'\\')
{
warn!(
context,
"Device token cannot be stored as metadata, ignoring."
);
return Ok(());
}
// Store the encrypted device token on the server.
//
// Chatmail relays accept the token and forward it to notifications server
// when a new message arrives but other servers may reject or ignore the metadata entry.
let command =
format!("SETMETADATA \"INBOX\" (/private/devicetoken \"{encrypted_device_token}\")");
if let Err(err) = self.run_command_and_check_ok(&command).await {
warn!(
context,
"Transport {transport_id}: Failed to store device token: {err:#}."
);
}
Ok(())
}
}
fn format_setmetadata(folder: &str, device_token: &str) -> String {
let device_token_len = device_token.len();
format!(
"SETMETADATA \"{folder}\" (/private/devicetoken {{{device_token_len}+}}\r\n{device_token})"
)
}
impl Session {
/// Returns success if we successfully set the flag or we otherwise
/// think add_flag should not be retried: Disconnection during setting
+2 -6
View File
@@ -25,12 +25,8 @@ pub(crate) struct Capabilities {
/// <https://tools.ietf.org/html/rfc4978>
pub can_compress: bool,
/// True if the server supports XDELTAPUSH capability.
/// This capability means setting /private/devicetoken IMAP METADATA
/// on the INBOX results in new mail notifications
/// via notifications.delta.chat service.
/// This is supported by <https://github.com/deltachat/chatmail>
pub can_push: bool,
/// True if the server advertises the legacy `XDELTAPUSH` capability.
pub has_xdeltapush: bool,
/// True if the server has an XCHATMAIL capability
/// indicating that it is a <https://github.com/deltachat/chatmail> server.
+1 -1
View File
@@ -82,7 +82,7 @@ pub(crate) async fn identify_server(
can_check_quota: caps.has_str("QUOTA"),
can_metadata: caps.has_str("METADATA"),
can_compress: caps.has_str("COMPRESS=DEFLATE"),
can_push: caps.has_str("XDELTAPUSH"),
has_xdeltapush: caps.has_str("XDELTAPUSH"),
is_chatmail: caps.has_str("XCHATMAIL"),
server_id,
};
-12
View File
@@ -116,15 +116,3 @@ fn test_uid_grouper() {
vec![("INBOX".to_string(), vec![1, 2, 3], "2:3".to_string())]
);
}
#[test]
fn test_setmetadata_device_token() {
assert_eq!(
format_setmetadata("INBOX", "foobarbaz"),
"SETMETADATA \"INBOX\" (/private/devicetoken {9+}\r\nfoobarbaz)"
);
assert_eq!(
format_setmetadata("INBOX", "foo\r\nbar\r\nbaz\r\n"),
"SETMETADATA \"INBOX\" (/private/devicetoken {15+}\r\nfoo\r\nbar\r\nbaz\r\n)"
);
}
+5 -4
View File
@@ -49,6 +49,10 @@ pub(crate) struct Session {
/// Should be false if no folder is currently selected.
pub new_mail: bool,
/// True if storing the device token on the relay
/// was already attempted on this connection.
pub(crate) push_token_registered: bool,
pub resync_request_sender: async_channel::Sender<()>,
}
@@ -81,6 +85,7 @@ impl Session {
selected_mailbox: None,
selected_folder_needs_expunge: false,
new_mail: false,
push_token_registered: false,
resync_request_sender,
}
}
@@ -106,10 +111,6 @@ impl Session {
self.capabilities.can_metadata
}
pub fn can_push(&self) -> bool {
self.capabilities.can_push
}
// Returns true if IMAP server has `XCHATMAIL` capability.
pub(crate) fn is_chatmail(&self) -> bool {
self.capabilities.is_chatmail
-1
View File
@@ -17,7 +17,6 @@
clippy::cloned_instead_of_copied,
clippy::manual_is_variant_and
)]
#![cfg_attr(not(test), warn(clippy::large_futures))]
#![cfg_attr(not(test), warn(clippy::arithmetic_side_effects))]
#![cfg_attr(not(test), forbid(clippy::indexing_slicing))]
#![cfg_attr(not(test), forbid(clippy::string_slice))]
-25
View File
@@ -7,14 +7,12 @@
//! which holds push notification token for the device,
//! shared by all accounts.
use std::sync::Arc;
use std::sync::atomic::Ordering;
use anyhow::{Context as _, Result};
use base64::Engine as _;
use pgp::crypto::aead::{AeadAlgorithm, ChunkSize};
use pgp::crypto::sym::SymmetricKeyAlgorithm;
use crate::context::Context;
use crate::key::DcKey;
/// Manages subscription to Apple Push Notification services.
@@ -117,29 +115,6 @@ impl PushSubscriber {
}
}
/// Push notification state
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, FromPrimitive, ToPrimitive)]
#[repr(i8)]
pub enum NotifyState {
/// Not subscribed to push notifications.
#[default]
NotConnected = 0,
/// Subscribed to push notifications for new messages.
Connected = 2,
}
impl Context {
/// Returns push notification subscriber state.
pub fn push_state(&self) -> NotifyState {
if self.push_subscribed.load(Ordering::Relaxed) {
NotifyState::Connected
} else {
NotifyState::NotConnected
}
}
}
#[cfg(test)]
mod tests {
use super::*;
+1 -1
View File
@@ -30,7 +30,7 @@ pub(crate) enum ConnectionSecurity {
/// Implicit TLS.
Tls,
// STARTTLS.
/// STARTTLS.
Starttls,
/// Plaintext.