Files
chatmail-core/src/transport.rs
holger krekel 70a01a6813 feat!: remove a relay immediately instead of unpublishing it
Removing a relay now takes effect immediately:

- the profile stops fetching and advertising it,

- secondary devices immediately apply the removal through the transport sync,

Upgrading removes unpublished relays and triggers keyupdates.

BREAKING CHANGE: set_transport_unpublished() is removed: UIs call delete_transport() when the user removes a relay.

BREAKING CHANGE: list_transports_ex() and the TransportListEntry type are removed: use list_transports().

BREAKING CHANGE: delete_transport() no longer refuses to remove the primary transport: it refuses only to remove the last one and re-elects the sending transport as needed.

BREAKING CHANGE: TransportsModified is now also emitted on the device modifying the transports, not only on devices applying the synced change.

Deprecated: DC_STR_PHASING_OUT
2026-09-01 14:15:26 +02:00

784 lines
26 KiB
Rust

//! # Message transport.
//!
//! A transport represents a single IMAP+SMTP configuration
//! that is known to work at least once in the past.
//!
//! Transports are stored in the `transports` SQL table.
//! Each transport is uniquely identified by its email address.
//! The table stores both the login parameters entered by the user
//! and configured list of connection candidates.
use std::fmt;
use std::sync::atomic::Ordering;
use anyhow::{Context as _, Result, bail, format_err};
use deltachat_contact_tools::{EmailAddress, addr_normalize};
use rusqlite::OptionalExtension;
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::context::Context;
use crate::ensure_and_debug_assert;
use crate::events::EventType;
use crate::keyupdate::{schedule_keyupdate_check, set_current_relays_as_keyupdate_baseline};
use crate::login_param::EnteredLoginParam;
use crate::net::load_connection_timestamp;
use crate::provider::Socket;
use crate::sql::Sql;
use crate::sync::{RemovedTransportData, SyncData, TransportData};
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) enum ConnectionSecurity {
/// Implicit TLS.
Tls,
/// STARTTLS.
Starttls,
/// Plaintext.
Plain,
}
impl fmt::Display for ConnectionSecurity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Tls => write!(f, "tls")?,
Self::Starttls => write!(f, "starttls")?,
Self::Plain => write!(f, "plain")?,
}
Ok(())
}
}
impl TryFrom<Socket> for ConnectionSecurity {
type Error = anyhow::Error;
fn try_from(socket: Socket) -> Result<Self> {
match socket {
Socket::Automatic => Err(format_err!("Socket security is not configured")),
Socket::Ssl => Ok(Self::Tls),
Socket::Starttls => Ok(Self::Starttls),
Socket::Plain => Ok(Self::Plain),
}
}
}
/// Values saved into `imap_certificate_checks`.
#[derive(
Copy, Clone, Debug, Display, FromPrimitive, ToPrimitive, PartialEq, Eq, Serialize, Deserialize,
)]
#[repr(u32)]
#[strum(serialize_all = "snake_case")]
pub(crate) enum ConfiguredCertificateChecks {
/// Accept invalid certificates.
///
/// Must not be saved by new versions.
///
/// Previous Delta Chat versions before core 1.133.0
/// stored this in `configured_imap_certificate_checks`
/// if Automatic configuration
/// was selected, configuration with strict TLS checks failed
/// and configuration without strict TLS checks succeeded.
OldAutomatic = 0,
/// Ensure that TLS certificate is valid for the server hostname.
Strict = 1,
/// Accept certificates that are expired, self-signed
/// or otherwise not valid for the server hostname.
AcceptInvalidCertificates = 2,
/// Accept certificates that are expired, self-signed
/// or otherwise not valid for the server hostname.
///
/// Alias to `AcceptInvalidCertificates` for compatibility.
AcceptInvalidCertificates2 = 3,
/// Apply strict checks to TLS certificates,
/// unless a legacy-domain override disables them.
Automatic = 4,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct ConnectionCandidate {
/// Server hostname or IP address.
pub host: String,
/// Server port.
pub port: u16,
/// Transport layer security.
pub security: ConnectionSecurity,
}
impl fmt::Display for ConnectionCandidate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}:{}", self.host, self.port, self.security)?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct ConfiguredServerLoginParam {
pub connection: ConnectionCandidate,
/// Username.
pub user: String,
}
impl fmt::Display for ConfiguredServerLoginParam {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Do not print the username,
// we do not want it to end up in the logs.
write!(f, "{}", self.connection)?;
Ok(())
}
}
pub(crate) async fn prioritize_server_login_params(
sql: &Sql,
params: &[ConfiguredServerLoginParam],
alpn: &str,
) -> Result<Vec<ConfiguredServerLoginParam>> {
let mut res: Vec<(Option<i64>, ConfiguredServerLoginParam)> = Vec::with_capacity(params.len());
for param in params {
let timestamp = load_connection_timestamp(
sql,
alpn,
&param.connection.host,
param.connection.port,
None,
)
.await?;
res.push((timestamp, param.clone()));
}
res.sort_by_key(|(ts, _param)| std::cmp::Reverse(*ts));
Ok(res.into_iter().map(|(_ts, param)| param).collect())
}
/// Login parameters saved to the database
/// after successful configuration.
#[derive(Debug, Clone, PartialEq, Eq)]
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 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 if non-empty.
pub smtp_user: String,
pub smtp_password: String,
/// TLS options: whether to allow invalid certificates and/or
/// invalid hostnames
pub certificate_checks: ConfiguredCertificateChecks,
}
/// JSON representation of ConfiguredLoginParam
/// for the database and sync messages.
#[derive(Debug, Serialize, Deserialize)]
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>,
pub smtp_user: String,
pub smtp_password: String,
pub certificate_checks: ConfiguredCertificateChecks,
/// Deprecated 2026-07, always false
#[serde(default)]
pub oauth2: bool,
}
impl fmt::Display for ConfiguredLoginParam {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let certificate_checks = self.certificate_checks;
if let Ok(parsed_addr) = EmailAddress::new(&self.addr) {
// Only include the domain.
write!(f, "***@{}", parsed_addr.domain)?;
} else {
// Should not happen, but if the address
// does not have a distinct domain part,
// print it as is.
write!(f, "{}", self.addr)?;
};
write!(f, " imap:[")?;
let mut first = true;
for imap in &self.imap {
if !first {
write!(f, ", ")?;
}
write!(f, "{imap}")?;
first = false;
}
write!(f, "]")?;
if let Some(folder) = &self.imap_folder {
write!(f, " folder:{folder:?}")?;
}
write!(f, " smtp:[")?;
let mut first = true;
for smtp in &self.smtp {
if !first {
write!(f, ", ")?;
}
write!(f, "{smtp}")?;
first = false;
}
write!(f, "] cert_{certificate_checks}")?;
Ok(())
}
}
impl ConfiguredLoginParam {
/// Load configured account settings from the database.
///
/// Returns transport ID and configured parameters
/// of the current primary transport.
/// Returns `None` if account is not configured.
pub(crate) async fn load(context: &Context) -> Result<Option<(u32, Self)>> {
let Some(self_addr) = context.get_config(Config::ConfiguredAddr).await? else {
return Ok(None);
};
let Some((id, json)) = context
.sql
.query_row_optional(
"SELECT id, configured_param FROM transports WHERE addr=?",
(&self_addr,),
|row| {
let id: u32 = row.get(0)?;
let json: String = row.get(1)?;
Ok((id, json))
},
)
.await?
else {
bail!("Self address {self_addr} doesn't have a corresponding transport");
};
Ok(Some((id, Self::from_json(&json)?)))
}
/// Loads configured login parameters for all transports.
///
/// Returns a vector of all transport IDs
/// paired with the configured parameters for the transports.
pub(crate) async fn load_all(context: &Context) -> Result<Vec<(u32, Self)>> {
context
.sql
.query_map_vec("SELECT id, configured_param FROM transports", (), |row| {
let id: u32 = row.get(0)?;
let json: String = row.get(1)?;
let param = Self::from_json(&json)?;
Ok((id, param))
})
.await
}
/// Loads legacy configured param. Only used for tests and the migration.
pub(crate) async fn load_legacy(context: &Context) -> Result<Option<Self>> {
if !context.get_config_bool(Config::Configured).await? {
return Ok(None);
}
let addr = context
.get_config(Config::ConfiguredAddr)
.await?
.unwrap_or_default()
.trim()
.to_string();
let certificate_checks: ConfiguredCertificateChecks = if let Some(certificate_checks) =
context
.get_config_parsed::<i32>(Config::ConfiguredImapCertificateChecks)
.await?
{
num_traits::FromPrimitive::from_i32(certificate_checks)
.context("Invalid configured_imap_certificate_checks value")?
} else {
// This is true for old accounts configured using C core
// which did not check TLS certificates.
ConfiguredCertificateChecks::OldAutomatic
};
let send_pw = context
.get_config(Config::ConfiguredSendPw)
.await?
.context("SMTP password is not configured")?;
let mail_pw = context
.get_config(Config::ConfiguredMailPw)
.await?
.context("IMAP password is not configured")?;
let imap;
let smtp;
let mail_user = context
.get_config(Config::ConfiguredMailUser)
.await?
.unwrap_or_default();
let send_user = context
.get_config(Config::ConfiguredSendUser)
.await?
.unwrap_or_default();
if let (Some(configured_mail_servers), Some(configured_send_servers)) = (
context.get_config(Config::ConfiguredImapServers).await?,
context.get_config(Config::ConfiguredSmtpServers).await?,
) {
imap = serde_json::from_str(&configured_mail_servers)
.context("Failed to parse configured IMAP servers")?;
smtp = serde_json::from_str(&configured_send_servers)
.context("Failed to parse configured SMTP servers")?;
} else {
// Load legacy settings storing a single IMAP and single SMTP server.
let mail_server = context
.get_config(Config::ConfiguredMailServer)
.await?
.unwrap_or_default();
let mail_port = context
.get_config_parsed::<u16>(Config::ConfiguredMailPort)
.await?
.unwrap_or_default();
let mail_security: Socket = context
.get_config_parsed::<i32>(Config::ConfiguredMailSecurity)
.await?
.and_then(num_traits::FromPrimitive::from_i32)
.unwrap_or_default();
let send_server = context
.get_config(Config::ConfiguredSendServer)
.await?
.context("SMTP server is not configured")?;
let send_port = context
.get_config_parsed::<u16>(Config::ConfiguredSendPort)
.await?
.unwrap_or_default();
let send_security: Socket = context
.get_config_parsed::<i32>(Config::ConfiguredSendSecurity)
.await?
.and_then(num_traits::FromPrimitive::from_i32)
.unwrap_or_default();
imap = vec![ConfiguredServerLoginParam {
connection: ConnectionCandidate {
host: mail_server,
port: mail_port,
security: mail_security.try_into()?,
},
user: mail_user.clone(),
}];
smtp = vec![ConfiguredServerLoginParam {
connection: ConnectionCandidate {
host: send_server,
port: send_port,
security: send_security.try_into()?,
},
user: send_user.clone(),
}];
}
Ok(Some(ConfiguredLoginParam {
addr,
imap,
imap_folder: None,
imap_user: mail_user,
imap_password: mail_pw,
smtp,
smtp_user: send_user,
smtp_password: send_pw,
certificate_checks,
}))
}
pub(crate) async fn save_to_transports_table(
self,
context: &Context,
entered_param: &EnteredLoginParam,
timestamp: i64,
) -> Result<()> {
save_transport(context, entered_param, &self.into(), timestamp).await?;
Ok(())
}
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"
);
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,
smtp_user: json.smtp_user,
smtp_password: json.smtp_password,
certificate_checks: json.certificate_checks,
})
}
pub(crate) fn into_json(self) -> Result<String> {
let json: ConfiguredLoginParamJson = self.into();
Ok(serde_json::to_string(&json)?)
}
pub(crate) fn strict_tls(&self, connected_through_proxy: bool) -> Result<bool> {
let disable_strict_tls =
crate::provider::legacy_settings_for_addr(&self.addr)?.disable_strict_tls;
Ok(match self.certificate_checks {
ConfiguredCertificateChecks::OldAutomatic if disable_strict_tls => false,
ConfiguredCertificateChecks::OldAutomatic => connected_through_proxy,
ConfiguredCertificateChecks::Automatic => !disable_strict_tls,
ConfiguredCertificateChecks::Strict => true,
ConfiguredCertificateChecks::AcceptInvalidCertificates
| ConfiguredCertificateChecks::AcceptInvalidCertificates2 => false,
})
}
}
impl From<ConfiguredLoginParam> for ConfiguredLoginParamJson {
fn from(configured_login_param: ConfiguredLoginParam) -> Self {
Self {
addr: configured_login_param.addr,
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,
certificate_checks: configured_login_param.certificate_checks,
oauth2: false,
}
}
}
/// Saves transport to the database.
/// Returns whether transports are modified.
pub(crate) async fn save_transport(
context: &Context,
entered_param: &EnteredLoginParam,
configured: &ConfiguredLoginParamJson,
add_timestamp: i64,
) -> 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(
"INSERT INTO transports (addr, entered_param, configured_param, add_timestamp)
VALUES (?, ?, ?, ?)
ON CONFLICT (addr)
DO UPDATE SET entered_param=excluded.entered_param,
configured_param=excluded.configured_param,
add_timestamp=excluded.add_timestamp
WHERE entered_param != excluded.entered_param
OR configured_param != excluded.configured_param
OR add_timestamp < excluded.add_timestamp",
(
&addr,
serde_json::to_string(entered_param)?,
serde_json::to_string(configured)?,
add_timestamp,
),
)
.await?
> 0;
if configured_addr.is_none() {
// If there is no transport yet, set the new transport as the primary one
context
.sql
.set_raw_config(Config::ConfiguredAddr.as_ref(), Some(&addr))
.await?;
modified = true;
}
Ok(modified)
}
/// Sends a sync message to synchronize transports across devices
/// and emits [`EventType::TransportsModified`].
pub(crate) async fn send_sync_transports(context: &Context) -> Result<()> {
info!(context, "Sending transport synchronization message.");
// Regenerate public key to include all transports.
context.self_public_key.lock().await.take();
// Synchronize all transport configurations.
//
// Transport with ID 1 is never synchronized
// because it can only be created during initial configuration.
// This also guarantees that credentials for the first
// transport are never sent in sync messages,
// so this is not worse than when not using multi-transport.
// If transport ID 1 is reconfigured,
// likely because the password has changed,
// user has to reconfigure it manually on all devices.
let transports = context
.sql
.query_map_vec(
"SELECT entered_param, configured_param, add_timestamp
FROM transports WHERE id>1",
(),
|row| {
let entered_json: String = row.get(0)?;
let entered: EnteredLoginParam = serde_json::from_str(&entered_json)?;
let configured_json: String = row.get(1)?;
let configured: ConfiguredLoginParamJson = serde_json::from_str(&configured_json)?;
let timestamp: i64 = row.get(2)?;
Ok(TransportData {
configured,
entered,
timestamp,
is_published: true,
})
},
)
.await?;
let removed_transports = context
.sql
.query_map_vec(
"SELECT addr, remove_timestamp FROM removed_transports",
(),
|row| {
let addr: String = row.get(0)?;
let timestamp: i64 = row.get(1)?;
Ok(RemovedTransportData { addr, timestamp })
},
)
.await?;
context
.add_sync_item(SyncData::Transports {
transports,
removed_transports,
})
.await?;
// Schedule the check before interrupting, so the woken SMTP loop sees it.
schedule_keyupdate_check(context).await?;
context.scheduler.interrupt_smtp().await;
context.emit_event(EventType::TransportsModified);
Ok(())
}
/// Process received data for transport synchronization.
///
/// A transport that an older core unpublished
/// arrives with `is_published: false` and is removed.
pub(crate) async fn sync_transports(
context: &Context,
transports: &[TransportData],
removed_transports: &[RemovedTransportData],
) -> Result<()> {
let mut modified = false;
for TransportData {
configured,
entered,
timestamp,
is_published,
} in transports
{
if !is_published {
continue;
}
let removed_later = context
.sql
.exists(
"SELECT COUNT(*) FROM removed_transports WHERE addr=? AND remove_timestamp>=?",
(&addr_normalize(&configured.addr), timestamp),
)
.await?;
if removed_later {
// Only a legacy core keeps syncing a transport that was removed here;
// it must not be resurrected, and removal wins a timestamp tie.
continue;
}
modified |= save_transport(context, entered, configured, *timestamp).await?;
}
let removals: Vec<(String, i64)> = removed_transports
.iter()
.map(|removed| (removed.addr.clone(), removed.timestamp))
.chain(
transports
.iter()
.filter(|data| !data.is_published)
.map(|data| (addr_normalize(&data.configured.addr), data.timestamp)),
)
.collect();
let (deleted_ids, reelected) = context
.sql
.transaction(|transaction| {
let mut deleted_ids = Vec::new();
for (addr, timestamp) in &removals {
if transport_addrs(transaction)?.len() <= 1 {
// Removing the last transport would unconfigure the account.
break;
}
deleted_ids.extend(delete_transport_row(transaction, addr, *timestamp)?);
}
modified |= !deleted_ids.is_empty();
let reelected = maybe_update_sending_transport(transaction)?;
Ok((deleted_ids, reelected))
})
.await?;
for transport_id in &deleted_ids {
purge_transport_caches(context, *transport_id).await;
}
if let Some(new_addr) = reelected {
info!(context, "Re-elected primary transport {new_addr:?}.");
context.sql.uncache_raw_config("configured_addr").await;
modified = true;
}
if modified {
context.self_public_key.lock().await.take();
context
.restart_io_after_fetch
.store(true, Ordering::Relaxed);
context.emit_event(EventType::TransportsModified);
// Without setting the baseline a restarting SMTP loop
// would send duplicate keyupdates on every second device.
// Acceptable gap: a user changing relay setup on two devices concurrently
// will not trigger a message with the "merged" set of the concurrent changes.
set_current_relays_as_keyupdate_baseline(context).await?;
}
Ok(())
}
/// Returns the addresses of all transports, newest first.
pub(crate) fn transport_addrs(transaction: &rusqlite::Transaction) -> Result<Vec<String>> {
let addrs = transaction
.prepare("SELECT addr FROM transports ORDER BY add_timestamp DESC, id DESC")?
.query_map((), |row| row.get(0))?
.collect::<rusqlite::Result<_>>()?;
Ok(addrs)
}
/// Deletes the transport row unless it is newer than `remove_timestamp`,
/// records the tombstone, and returns the deleted row's id.
pub(crate) fn delete_transport_row(
transaction: &mut rusqlite::Transaction,
addr: &str,
remove_timestamp: i64,
) -> Result<Option<u32>> {
let deleted_id: Option<u32> = transaction
.query_row(
"DELETE FROM transports
WHERE addr=? AND add_timestamp<=?
RETURNING id",
(addr, remove_timestamp),
|row| row.get(0),
)
.optional()?;
transaction.execute(
"INSERT INTO removed_transports (addr, remove_timestamp)
VALUES (?, ?)
ON CONFLICT (addr) DO
UPDATE SET remove_timestamp = excluded.remove_timestamp
WHERE excluded.remove_timestamp > remove_timestamp",
(addr, remove_timestamp),
)?;
Ok(deleted_id)
}
/// Drops the per-process caches of a removed transport.
pub(crate) async fn purge_transport_caches(context: &Context, transport_id: u32) {
context.quota.write().await.remove(&transport_id);
context.metadata.write().await.remove(&transport_id);
}
/// Elects another transport for sending if the current one vanished.
/// Any remaining transport works and selection is anyway moving
/// to the authority of the SMTP loop, see <https://github.com/chatmail/core/pull/8619>
pub(crate) fn maybe_update_sending_transport(
transaction: &mut rusqlite::Transaction,
) -> Result<Option<String>> {
let configured_addr: String = transaction.query_row(
"SELECT value FROM config WHERE keyname='configured_addr'",
(),
|row| row.get(0),
)?;
let addrs = transport_addrs(transaction)?;
if addrs.contains(&configured_addr) {
return Ok(None);
}
let Some(new_addr) = addrs.into_iter().next() else {
return Ok(None);
};
transaction.execute(
"UPDATE config SET value=? WHERE keyname='configured_addr'",
(&new_addr,),
)?;
Ok(Some(new_addr))
}
/// Adds transport entry to the `transports` table with empty configuration.
pub(crate) async fn add_pseudo_transport(context: &Context, addr: &str) -> Result<()> {
context.sql
.execute(
"INSERT OR IGNORE INTO transports (addr, entered_param, configured_param) VALUES (?, ?, ?)",
(
addr,
serde_json::to_string(&EnteredLoginParam{addr: addr.to_string(), ..Default::default()})?,
format!(r#"{{"addr":"{addr}","imap":[],"imap_user":"","imap_password":"","smtp":[],"smtp_user":"","smtp_password":"","certificate_checks":"Automatic","oauth2":false}}"#)
),
)
.await?;
Ok(())
}
#[cfg(test)]
mod transport_tests;