Compare commits

..

16 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
ac78f76602 Fix unused variable warning and run cargo fmt
Co-authored-by: link2xt <18373967+link2xt@users.noreply.github.com>
2025-11-07 00:07:51 +00:00
copilot-swe-agent[bot]
94c373368c Simplify calls table with single sdp column instead of offer_sdp and answer_sdp
Co-authored-by: link2xt <18373967+link2xt@users.noreply.github.com>
2025-11-06 23:10:34 +00:00
copilot-swe-agent[bot]
76db7853ff Use SystemTime::shift instead of manually rewinding timestamps
Co-authored-by: link2xt <18373967+link2xt@users.noreply.github.com>
2025-11-06 22:44:31 +00:00
copilot-swe-agent[bot]
d946979b88 Use UPSERT for ended_timestamp to avoid replacing SDP data
Co-authored-by: link2xt <18373967+link2xt@users.noreply.github.com>
2025-11-06 22:34:57 +00:00
copilot-swe-agent[bot]
12b21c6cb3 Fix test to use ended_timestamp instead of timestamp_sent
Co-authored-by: link2xt <18373967+link2xt@users.noreply.github.com>
2025-11-06 22:31:12 +00:00
copilot-swe-agent[bot]
1a04fc5db3 Address review feedback: use Message field for SDP, remove table id, store ended_timestamp
Co-authored-by: link2xt <18373967+link2xt@users.noreply.github.com>
2025-11-06 22:28:26 +00:00
copilot-swe-agent[bot]
55cf576e13 Add clarifying comments and improve documentation based on code review
Co-authored-by: link2xt <18373967+link2xt@users.noreply.github.com>
2025-11-06 20:17:01 +00:00
copilot-swe-agent[bot]
c6a871c64a Improve calls table with FOREIGN KEY, STRICT mode, and better housekeeping query
Co-authored-by: link2xt <18373967+link2xt@users.noreply.github.com>
2025-11-06 20:14:49 +00:00
copilot-swe-agent[bot]
19be18dcbf Add test for housekeeping cleanup of old call SDPs
Co-authored-by: link2xt <18373967+link2xt@users.noreply.github.com>
2025-11-06 20:11:06 +00:00
copilot-swe-agent[bot]
c556b07380 Add calls table to store SDPs separately from message params
Co-authored-by: link2xt <18373967+link2xt@users.noreply.github.com>
2025-11-06 20:06:47 +00:00
copilot-swe-agent[bot]
abece73db1 Initial plan 2025-11-06 19:33:55 +00:00
link2xt
7fef812b1e refactor(imap): move resync request from Context to Imap
For multiple transports we will need to run
multiple IMAP clients in parallel.
UID validity change detected by one IMAP client
should not result in UID resync
for another IMAP client.
2025-11-06 19:16:30 +00:00
link2xt
5f174ceaf2 test: test editing saved messages 2025-11-06 18:38:11 +00:00
link2xt
06b038ab5d fix: is_encrypted() should be true for Saved Messages chat
Otherwise UIs don't allow to edit messages sent to self.
This was likely broken in b417ba86bc
2025-11-06 18:38:11 +00:00
Simon Laux
b20da3cb0e docs: readme: update language binding section to avoid usage of cffi in new projects (#7380)
Updated language bindings section to reflect deprecation of
`libdeltachat and removed outdated entries.
2025-11-06 13:04:56 +00:00
Simon Laux
a3328ea2de api!(jsonrpc): chat_type now contains a variant of a string enum/union. Affected places: FullChat.chat_type, BasicChat.chat_type, ChatListItemFetchResult::ChatListItem.chat_type, Event:: SecurejoinInviterProgress.chat_type and MessageSearchResult.chat_type (#7285)
Actually it will be not as breaking if you used the constants, because
this pr also changes the constants.

closes #7029 

Note that I had to change the constants from enum to namespace, this has
the side effect, that you can no longer also use the constants as types,
you need to instead prefix them with `typeof ` now.
2025-11-06 12:53:48 +00:00
21 changed files with 402 additions and 112 deletions

View File

@@ -197,12 +197,10 @@ and then run the script.
Language bindings are available for:
- **C** \[[📂 source](./deltachat-ffi) | [📚 docs](https://c.delta.chat)\]
- -> libdeltachat is going to be deprecated and only exists because Android, iOS and Ubuntu Touch are still using it. If you build a new project, then please use the jsonrpc api instead.
- **JS**: \[[📂 source](./deltachat-rpc-client) | [📦 npm](https://www.npmjs.com/package/@deltachat/jsonrpc-client) | [📚 docs](https://js.jsonrpc.delta.chat/)\]
- **Python** \[[📂 source](./python) | [📦 pypi](https://pypi.org/project/deltachat) | [📚 docs](https://py.delta.chat)\]
- **Go**
- over jsonrpc: \[[📂 source](https://github.com/deltachat/deltachat-rpc-client-go/)\]
- over cffi[^1]: \[[📂 source](https://github.com/deltachat/go-deltachat/)\]
- **Free Pascal**[^1] \[[📂 source](https://github.com/deltachat/deltachat-fp/)\]
- **Go** \[[📂 source](https://github.com/deltachat/deltachat-rpc-client-go/)\]
- **Java** and **Swift** (contained in the Android/iOS repos)
The following "frontend" projects make use of the Rust-library
@@ -215,5 +213,3 @@ or its language bindings:
- [Telepathy](https://code.ur.gs/lupine/telepathy-padfoot/)
- [Ubuntu Touch](https://codeberg.org/lk108/deltatouch)
- several **Bots**
[^1]: Out of date / unmaintained, if you like those languages feel free to start maintaining them. If you have questions we'll help you, please ask in the issues.

View File

@@ -6,7 +6,6 @@ use deltachat::chat::{Chat, ChatId};
use deltachat::constants::Chattype;
use deltachat::contact::{Contact, ContactId};
use deltachat::context::Context;
use num_traits::cast::ToPrimitive;
use serde::{Deserialize, Serialize};
use typescript_type_def::TypeDef;
@@ -46,7 +45,7 @@ pub struct FullChat {
archived: bool,
pinned: bool,
// subtitle - will be moved to frontend because it uses translation functions
chat_type: u32,
chat_type: JsonrpcChatType,
is_unpromoted: bool,
is_self_talk: bool,
contacts: Vec<ContactObject>,
@@ -130,7 +129,7 @@ impl FullChat {
profile_image, //BLOBS ?
archived: chat.get_visibility() == chat::ChatVisibility::Archived,
pinned: chat.get_visibility() == chat::ChatVisibility::Pinned,
chat_type: chat.get_type().to_u32().context("unknown chat type id")?,
chat_type: chat.get_type().into(),
is_unpromoted: chat.is_unpromoted(),
is_self_talk: chat.is_self_talk(),
contacts,
@@ -192,7 +191,7 @@ pub struct BasicChat {
profile_image: Option<String>, //BLOBS ?
archived: bool,
pinned: bool,
chat_type: u32,
chat_type: JsonrpcChatType,
is_unpromoted: bool,
is_self_talk: bool,
color: String,
@@ -220,7 +219,7 @@ impl BasicChat {
profile_image, //BLOBS ?
archived: chat.get_visibility() == chat::ChatVisibility::Archived,
pinned: chat.get_visibility() == chat::ChatVisibility::Pinned,
chat_type: chat.get_type().to_u32().context("unknown chat type id")?,
chat_type: chat.get_type().into(),
is_unpromoted: chat.is_unpromoted(),
is_self_talk: chat.is_self_talk(),
color,
@@ -274,3 +273,37 @@ impl JsonrpcChatVisibility {
}
}
}
#[derive(Clone, Serialize, Deserialize, PartialEq, TypeDef, schemars::JsonSchema)]
#[serde(rename = "ChatType")]
pub enum JsonrpcChatType {
Single,
Group,
Mailinglist,
OutBroadcast,
InBroadcast,
}
impl From<Chattype> for JsonrpcChatType {
fn from(chattype: Chattype) -> Self {
match chattype {
Chattype::Single => JsonrpcChatType::Single,
Chattype::Group => JsonrpcChatType::Group,
Chattype::Mailinglist => JsonrpcChatType::Mailinglist,
Chattype::OutBroadcast => JsonrpcChatType::OutBroadcast,
Chattype::InBroadcast => JsonrpcChatType::InBroadcast,
}
}
}
impl From<JsonrpcChatType> for Chattype {
fn from(chattype: JsonrpcChatType) -> Self {
match chattype {
JsonrpcChatType::Single => Chattype::Single,
JsonrpcChatType::Group => Chattype::Group,
JsonrpcChatType::Mailinglist => Chattype::Mailinglist,
JsonrpcChatType::OutBroadcast => Chattype::OutBroadcast,
JsonrpcChatType::InBroadcast => Chattype::InBroadcast,
}
}
}

View File

@@ -11,6 +11,7 @@ use num_traits::cast::ToPrimitive;
use serde::Serialize;
use typescript_type_def::TypeDef;
use super::chat::JsonrpcChatType;
use super::color_int_to_hex_string;
use super::message::MessageViewtype;
@@ -23,7 +24,7 @@ pub enum ChatListItemFetchResult {
name: String,
avatar_path: Option<String>,
color: String,
chat_type: u32,
chat_type: JsonrpcChatType,
last_updated: Option<i64>,
summary_text1: String,
summary_text2: String,
@@ -151,7 +152,7 @@ pub(crate) async fn get_chat_list_item_by_id(
name: chat.get_name().to_owned(),
avatar_path,
color,
chat_type: chat.get_type().to_u32().context("unknown chat type id")?,
chat_type: chat.get_type().into(),
last_updated,
summary_text1,
summary_text2,

View File

@@ -1,8 +1,9 @@
use deltachat::{Event as CoreEvent, EventType as CoreEventType};
use num_traits::ToPrimitive;
use serde::Serialize;
use typescript_type_def::TypeDef;
use super::chat::JsonrpcChatType;
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct Event {
@@ -307,7 +308,7 @@ pub enum EventType {
/// The type of the joined chat.
/// This can take the same values
/// as `BasicChat.chatType` ([`crate::api::types::chat::BasicChat::chat_type`]).
chat_type: u32,
chat_type: JsonrpcChatType,
/// ID of the chat in case of success.
chat_id: u32,
@@ -570,7 +571,7 @@ impl From<CoreEventType> for EventType {
progress,
} => SecurejoinInviterProgress {
contact_id: contact_id.to_u32(),
chat_type: chat_type.to_u32().unwrap_or(0),
chat_type: chat_type.into(),
chat_id: chat_id.to_u32(),
progress,
},

View File

@@ -16,6 +16,7 @@ use num_traits::cast::ToPrimitive;
use serde::{Deserialize, Serialize};
use typescript_type_def::TypeDef;
use super::chat::JsonrpcChatType;
use super::color_int_to_hex_string;
use super::contact::ContactObject;
use super::reactions::JsonrpcReactions;
@@ -531,7 +532,7 @@ pub struct MessageSearchResult {
chat_profile_image: Option<String>,
chat_color: String,
chat_name: String,
chat_type: u32,
chat_type: JsonrpcChatType,
is_chat_contact_request: bool,
is_chat_archived: bool,
message: String,
@@ -569,7 +570,7 @@ impl MessageSearchResult {
chat_id: chat.id.to_u32(),
chat_name: chat.get_name().to_owned(),
chat_color,
chat_type: chat.get_type().to_u32().context("unknown chat type id")?,
chat_type: chat.get_type().into(),
chat_profile_image,
is_chat_contact_request: chat.is_contact_request(),
is_chat_archived: chat.get_visibility() == ChatVisibility::Archived,

View File

@@ -45,15 +45,30 @@ const constants = data
key.startsWith("DC_SOCKET_") ||
key.startsWith("DC_LP_AUTH_") ||
key.startsWith("DC_PUSH_") ||
key.startsWith("DC_TEXT1_")
key.startsWith("DC_TEXT1_") ||
key.startsWith("DC_CHAT_TYPE")
);
})
.map((row) => {
return ` ${row.key}: ${row.value}`;
return ` export const ${row.key} = ${row.value};`;
})
.join(",\n");
.join("\n");
writeFileSync(
resolve(__dirname, "../generated/constants.ts"),
`// Generated!\n\nexport enum C {\n${constants.replace(/:/g, " =")},\n}\n`,
`// Generated!
export namespace C {
${constants}
/** @deprecated 10-8-2025 compare string directly with \`== "Group"\` */
export const DC_CHAT_TYPE_GROUP = "Group";
/** @deprecated 10-8-2025 compare string directly with \`== "InBroadcast"\`*/
export const DC_CHAT_TYPE_IN_BROADCAST = "InBroadcast";
/** @deprecated 10-8-2025 compare string directly with \`== "Mailinglist"\` */
export const DC_CHAT_TYPE_MAILINGLIST = "Mailinglist";
/** @deprecated 10-8-2025 compare string directly with \`== "OutBroadcast"\` */
export const DC_CHAT_TYPE_OUT_BROADCAST = "OutBroadcast";
/** @deprecated 10-8-2025 compare string directly with \`== "Single"\` */
export const DC_CHAT_TYPE_SINGLE = "Single";
}\n`,
);

View File

@@ -91,19 +91,17 @@ class ChatId(IntEnum):
LAST_SPECIAL = 9
class ChatType(IntEnum):
class ChatType(str, Enum):
"""Chat type."""
UNDEFINED = 0
SINGLE = 100
SINGLE = "Single"
"""1:1 chat, i.e. a direct chat with a single contact"""
GROUP = 120
GROUP = "Group"
MAILINGLIST = 140
MAILINGLIST = "Mailinglist"
OUT_BROADCAST = 160
OUT_BROADCAST = "OutBroadcast"
"""Outgoing broadcast channel, called "Channel" in the UI.
The user can send into this channel,
@@ -115,7 +113,7 @@ class ChatType(IntEnum):
which would make it hard to grep for it.
"""
IN_BROADCAST = 165
IN_BROADCAST = "InBroadcast"
"""Incoming broadcast channel, called "Channel" in the UI.
This channel is read-only,

View File

@@ -135,8 +135,19 @@ impl CallInfo {
}
async fn mark_as_ended(&mut self, context: &Context) -> Result<()> {
self.msg.param.set_i64(CALL_ENDED_TIMESTAMP, time());
let now = time();
self.msg.param.set_i64(CALL_ENDED_TIMESTAMP, now);
self.msg.update_param(context).await?;
// Store ended timestamp in calls table. If no entry exists yet, create one.
context
.sql
.execute(
"INSERT INTO calls (msg_id, ended_timestamp) VALUES (?, ?)
ON CONFLICT(msg_id) DO UPDATE SET ended_timestamp=excluded.ended_timestamp",
(self.msg.id, now),
)
.await?;
Ok(())
}
@@ -152,6 +163,16 @@ impl CallInfo {
self.msg.param.set_i64(CALL_ENDED_TIMESTAMP, now);
self.msg.param.set_i64(CALL_CANCELED_TIMESTAMP, now);
self.msg.update_param(context).await?;
// Store ended timestamp in calls table. If no entry exists yet, create one.
context
.sql
.execute(
"INSERT INTO calls (msg_id, ended_timestamp) VALUES (?, ?)
ON CONFLICT(msg_id) DO UPDATE SET ended_timestamp=excluded.ended_timestamp",
(self.msg.id, now),
)
.await?;
Ok(())
}
@@ -193,11 +214,15 @@ impl Context {
let mut call = Message {
viewtype: Viewtype::Call,
text: "Outgoing call".into(),
call_sdp_offer: Some(place_call_info.clone()),
..Default::default()
};
call.param.set(Param::WebrtcRoom, &place_call_info);
call.id = send_msg(self, chat_id, &mut call).await?;
// For outgoing calls, we don't store our own offer SDP in the database.
// It's only kept in memory for sending the message.
let wait = RINGING_SECONDS;
task::spawn(Context::emit_end_call_if_unaccepted(
self.clone(),
@@ -229,6 +254,14 @@ impl Context {
chat.id.accept(self).await?;
}
// Store our answer SDP in calls table (replacing the offer from the other side)
self.sql
.execute(
"UPDATE calls SET sdp=? WHERE msg_id=?",
(&accept_call_info, call_id),
)
.await?;
// send an acceptance message around: to the caller as well as to the other devices of the callee
let mut msg = Message {
viewtype: Viewtype::Text,
@@ -237,8 +270,6 @@ impl Context {
};
msg.param.set_cmd(SystemMessage::CallAccepted);
msg.hidden = true;
msg.param
.set(Param::WebrtcAccepted, accept_call_info.to_string());
msg.set_quote(self, Some(&call.msg)).await?;
msg.id = send_msg(self, call.msg.chat_id, &mut msg).await?;
self.emit_event(EventType::IncomingCallAccepted {
@@ -327,6 +358,16 @@ impl Context {
from_id: ContactId,
) -> Result<()> {
if mime_message.is_call() {
// Extract SDP offer from message headers and store in calls table for incoming calls
if let Some(offer_sdp) = mime_message.get_header(HeaderDef::ChatWebrtcRoom) {
self.sql
.execute(
"INSERT OR IGNORE INTO calls (msg_id, sdp) VALUES (?, ?)",
(call_id, offer_sdp),
)
.await?;
}
let Some(call) = self.load_call_by_id(call_id).await? else {
warn!(self, "{call_id} does not refer to a call message");
return Ok(());
@@ -391,6 +432,18 @@ impl Context {
return Ok(());
}
// Store SDP answer in calls table for outgoing calls
// (for incoming calls, we've already replaced our offer with our answer in accept_incoming_call)
if let Some(answer_sdp) = mime_message.get_header(HeaderDef::ChatWebrtcAccepted)
{
self.sql
.execute(
"INSERT OR REPLACE INTO calls (msg_id, sdp) VALUES (?, ?)",
(call.msg.id, answer_sdp),
)
.await?;
}
call.mark_as_accepted(self).await?;
self.emit_msgs_changed(call.msg.chat_id, call_id);
if call.is_incoming() {
@@ -463,33 +516,49 @@ impl Context {
/// not a call message, returns `None`.
pub async fn load_call_by_id(&self, call_id: MsgId) -> Result<Option<CallInfo>> {
let call = Message::load_from_db(self, call_id).await?;
Ok(self.load_call_by_message(call))
self.load_call_by_message(call).await
}
// Loads information about the call given the `Message`.
//
// If the `Message` is not a call message, returns `None`
fn load_call_by_message(&self, call: Message) -> Option<CallInfo> {
// If the `Message` is not a call message, returns `None`.
//
// This function is async because it queries the calls table
// to retrieve SDP offers and answers.
async fn load_call_by_message(&self, call: Message) -> Result<Option<CallInfo>> {
if call.viewtype != Viewtype::Call {
// This can happen e.g. if a "call accepted"
// or "call ended" message is received
// with `In-Reply-To` referring to non-call message.
return None;
return Ok(None);
}
Some(CallInfo {
place_call_info: call
.param
.get(Param::WebrtcRoom)
.unwrap_or_default()
.to_string(),
accept_call_info: call
.param
.get(Param::WebrtcAccepted)
.unwrap_or_default()
.to_string(),
// Load SDP from calls table. Returns empty strings if no record exists,
// which can happen for old messages from before the migration or for
// calls where SDPs have been cleaned up by housekeeping.
// For incoming calls, the SDP is the offer from the other side.
// For outgoing calls (after acceptance), the SDP is the answer from the other side.
let sdp = self
.sql
.query_row_optional("SELECT sdp FROM calls WHERE msg_id=?", (call.id,), |row| {
row.get::<_, String>(0)
})
.await?
.unwrap_or_default();
let (place_call_info, accept_call_info) = if call.from_id == ContactId::SELF {
// Outgoing call: the stored SDP (if any) is the answer from the other side
(String::new(), sdp)
} else {
// Incoming call: the stored SDP is the offer from the other side
(sdp, String::new())
};
Ok(Some(CallInfo {
place_call_info,
accept_call_info,
msg: call,
})
}))
}
}

View File

@@ -4,6 +4,8 @@ use crate::config::Config;
use crate::constants::DC_CHAT_ID_TRASH;
use crate::receive_imf::{receive_imf, receive_imf_from_inbox};
use crate::test_utils::{TestContext, TestContextManager};
use crate::tools::SystemTime;
use std::time::Duration;
struct CallSetup {
pub alice: TestContext,
@@ -672,3 +674,74 @@ async fn test_no_partial_calls() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_housekeeping_deletes_old_call_sdps() -> Result<()> {
use crate::sql::housekeeping;
let alice = TestContext::new_alice().await;
// Simulate receiving an incoming call from Bob
let received_call = receive_imf(
&alice,
b"From: bob@example.net\n\
To: alice@example.org\n\
Message-ID: <incoming-call@example.net>\n\
Chat-Version: 1.0\n\
Chat-Content: call\n\
Chat-Webrtc-Room: dGVzdC1zZHAtb2ZmZXI=\n\
\n\
Hello, this is a call\n",
false,
)
.await?
.unwrap();
let call_id = received_call.msg_ids[0];
// Verify SDP is stored in calls table for incoming call
let sdp_before: Option<String> = alice
.sql
.query_row_optional("SELECT sdp FROM calls WHERE msg_id=?", (call_id,), |row| {
row.get(0)
})
.await?;
assert!(sdp_before.is_some());
// End the call
alice.end_call(call_id).await?;
// Verify the call message is marked as ended
let call = alice.load_call_by_id(call_id).await?.unwrap();
assert!(call.is_ended());
// SDP should still be there after ending
let sdp_after_end: Option<String> = alice
.sql
.query_row_optional("SELECT sdp FROM calls WHERE msg_id=?", (call_id,), |row| {
row.get(0)
})
.await?;
assert!(sdp_after_end.is_some());
// Simulate passage of time - shift forward by 24 hours + 1 second
SystemTime::shift(Duration::from_secs(86400 + 1));
// Run housekeeping
housekeeping(&alice).await?;
// Verify SDP has been deleted from calls table
let sdp_after_housekeeping: Option<String> = alice
.sql
.query_row_optional("SELECT sdp FROM calls WHERE msg_id=?", (call_id,), |row| {
row.get(0)
})
.await?;
assert_eq!(sdp_after_housekeeping, None);
// The call message should still exist
let msg = Message::load_from_db(&alice, call_id).await?;
assert_eq!(msg.viewtype, Viewtype::Call);
Ok(())
}

View File

@@ -1643,36 +1643,37 @@ impl Chat {
/// Returns true if the chat is encrypted.
pub async fn is_encrypted(&self, context: &Context) -> Result<bool> {
let is_encrypted = match self.typ {
Chattype::Single => {
match context
.sql
.query_row_optional(
"SELECT cc.contact_id, c.fingerprint<>''
let is_encrypted = self.is_self_talk()
|| match self.typ {
Chattype::Single => {
match context
.sql
.query_row_optional(
"SELECT cc.contact_id, c.fingerprint<>''
FROM chats_contacts cc LEFT JOIN contacts c
ON c.id=cc.contact_id
WHERE cc.chat_id=?
",
(self.id,),
|row| {
let id: ContactId = row.get(0)?;
let is_key: bool = row.get(1)?;
Ok((id, is_key))
},
)
.await?
{
Some((id, is_key)) => is_key || id == ContactId::DEVICE,
None => true,
(self.id,),
|row| {
let id: ContactId = row.get(0)?;
let is_key: bool = row.get(1)?;
Ok((id, is_key))
},
)
.await?
{
Some((id, is_key)) => is_key || id == ContactId::DEVICE,
None => true,
}
}
}
Chattype::Group => {
// Do not encrypt ad-hoc groups.
!self.grpid.is_empty()
}
Chattype::Mailinglist => false,
Chattype::OutBroadcast | Chattype::InBroadcast => true,
};
Chattype::Group => {
// Do not encrypt ad-hoc groups.
!self.grpid.is_empty()
}
Chattype::Mailinglist => false,
Chattype::OutBroadcast | Chattype::InBroadcast => true,
};
Ok(is_encrypted)
}

View File

@@ -801,6 +801,7 @@ async fn test_self_talk() -> Result<()> {
let chat = &t.get_self_chat().await;
assert!(!chat.id.is_special());
assert!(chat.is_self_talk());
assert!(chat.is_encrypted(&t).await?);
assert!(chat.visibility == ChatVisibility::Normal);
assert!(!chat.is_device_talk());
assert!(chat.can_send(&t).await?);
@@ -5085,6 +5086,28 @@ async fn test_send_edit_request() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_edit_saved_messages() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice1 = &tcm.alice().await;
let alice2 = &tcm.alice().await;
alice1.set_config_bool(Config::BccSelf, true).await?;
alice2.set_config_bool(Config::BccSelf, true).await?;
let alice1_chat_id = ChatId::create_for_contact(alice1, ContactId::SELF).await?;
let alice1_sent_msg = alice1.send_text(alice1_chat_id, "Original message").await;
let alice1_msg_id = alice1_sent_msg.sender_msg_id;
let received_msg = alice2.recv_msg(&alice1_sent_msg).await;
assert_eq!(received_msg.text, "Original message");
send_edit_request(alice1, alice1_msg_id, "Edited message".to_string()).await?;
alice2.recv_msg_trash(&alice1.pop_sent_msg().await).await;
let received_msg = Message::load_from_db(alice2, received_msg.id).await?;
assert_eq!(received_msg.text, "Edited message");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_receive_edit_request_after_removal() -> Result<()> {
let mut tcm = TestContextManager::new();

View File

@@ -565,14 +565,6 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
progress!(ctx, 910);
if let Some(configured_addr) = ctx.get_config(Config::ConfiguredAddr).await? {
if configured_addr != param.addr {
// Switched account, all server UIDs we know are invalid
info!(ctx, "Scheduling resync because the address has changed.");
ctx.schedule_resync().await?;
}
}
let provider = configured_param.provider;
configured_param
.save_to_transports_table(ctx, param)

View File

@@ -4,7 +4,7 @@ use std::collections::{BTreeMap, HashMap};
use std::ffi::OsString;
use std::ops::Deref;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
@@ -243,9 +243,6 @@ pub struct InnerContext {
/// Set to `None` if quota was never tried to load.
pub(crate) quota: RwLock<Option<QuotaInfo>>,
/// IMAP UID resync request.
pub(crate) resync_request: AtomicBool,
/// Notify about new messages.
///
/// This causes [`Context::wait_next_msgs`] to wake up.
@@ -457,7 +454,6 @@ impl Context {
scheduler: SchedulerState::new(),
ratelimit: RwLock::new(Ratelimit::new(Duration::new(60, 0), 6.0)), // Allow at least 1 message every 10 seconds + a burst of 6.
quota: RwLock::new(None),
resync_request: AtomicBool::new(false),
new_msgs_notify,
server_id: RwLock::new(None),
metadata: RwLock::new(None),
@@ -616,12 +612,6 @@ impl Context {
Ok(())
}
pub(crate) async fn schedule_resync(&self) -> Result<()> {
self.resync_request.store(true, Ordering::Relaxed);
self.scheduler.interrupt_inbox().await;
Ok(())
}
/// Returns a reference to the underlying SQL instance.
///
/// Warning: this is only here for testing, not part of the public API.

View File

@@ -104,6 +104,12 @@ pub(crate) struct Imap {
/// immediately after logging in or returning an error in response to LOGIN command
/// due to internal server error.
ratelimit: Ratelimit,
/// IMAP UID resync request sender.
pub(crate) resync_request_sender: async_channel::Sender<()>,
/// IMAP UID resync request receiver.
pub(crate) resync_request_receiver: async_channel::Receiver<()>,
}
#[derive(Debug)]
@@ -254,6 +260,7 @@ impl Imap {
oauth2: bool,
idle_interrupt_receiver: Receiver<()>,
) -> Self {
let (resync_request_sender, resync_request_receiver) = async_channel::bounded(1);
Imap {
idle_interrupt_receiver,
addr: addr.to_string(),
@@ -268,6 +275,8 @@ impl Imap {
conn_backoff_ms: 0,
// 1 connection per minute + a burst of 2.
ratelimit: Ratelimit::new(Duration::new(120, 0), 2.0),
resync_request_sender,
resync_request_receiver,
}
}
@@ -392,6 +401,7 @@ impl Imap {
match login_res {
Ok(mut session) => {
let capabilities = determine_capabilities(&mut session).await?;
let resync_request_sender = self.resync_request_sender.clone();
let session = if capabilities.can_compress {
info!(context, "Enabling IMAP compression.");
@@ -402,9 +412,9 @@ impl Imap {
})
.await
.context("Failed to enable IMAP compression")?;
Session::new(compressed_session, capabilities)
Session::new(compressed_session, capabilities, resync_request_sender)
} else {
Session::new(session, capabilities)
Session::new(session, capabilities, resync_request_sender)
};
// Store server ID in the context to display in account info.

View File

@@ -206,7 +206,7 @@ impl ImapSession {
"The server illegally decreased the uid_next of folder {folder:?} from {old_uid_next} to {new_uid_next} without changing validity ({new_uid_validity}), resyncing UIDs...",
);
set_uid_next(context, folder, new_uid_next).await?;
context.schedule_resync().await?;
self.resync_request_sender.try_send(()).ok();
}
// If UIDNEXT changed, there are new emails.
@@ -243,7 +243,7 @@ impl ImapSession {
.await?;
if old_uid_validity != 0 || old_uid_next != 0 {
context.schedule_resync().await?;
self.resync_request_sender.try_send(()).ok();
}
info!(
context,

View File

@@ -48,6 +48,8 @@ pub(crate) struct Session {
///
/// Should be false if no folder is currently selected.
pub new_mail: bool,
pub resync_request_sender: async_channel::Sender<()>,
}
impl Deref for Session {
@@ -68,6 +70,7 @@ impl Session {
pub(crate) fn new(
inner: ImapSession<Box<dyn SessionStream>>,
capabilities: Capabilities,
resync_request_sender: async_channel::Sender<()>,
) -> Self {
Self {
inner,
@@ -77,6 +80,7 @@ impl Session {
selected_folder_needs_expunge: false,
last_full_folder_scan: Mutex::new(None),
new_mail: false,
resync_request_sender,
}
}

View File

@@ -443,6 +443,13 @@ pub struct Message {
pub(crate) location_id: u32,
pub(crate) error: Option<String>,
pub(crate) param: Params,
/// SDP offer for outgoing calls.
/// This field is used to pass the SDP offer to the database
/// without storing it in message parameters.
/// It is not persisted in the msgs table, only in the calls table.
#[serde(skip)]
pub(crate) call_sdp_offer: Option<String>,
}
impl Message {
@@ -572,6 +579,7 @@ impl Message {
chat_blocked: row
.get::<_, Option<Blocked>>("blocked")?
.unwrap_or_default(),
call_sdp_offer: None,
};
Ok(msg)
},

View File

@@ -1680,6 +1680,42 @@ impl MimeFactory {
"Chat-Content",
mail_builder::headers::raw::Raw::new("call-accepted").into(),
));
// Get SDP answer from the referenced call message in calls table,
// or fall back to params if not yet migrated
if let Some(ref quoted_rfc724_mid) = msg.in_reply_to {
// Look up msg_id from rfc724_mid first
let quoted_msg_id: Option<MsgId> = context
.sql
.query_row_optional(
"SELECT id FROM msgs WHERE rfc724_mid=?",
(quoted_rfc724_mid,),
|row| row.get(0),
)
.await?;
if let Some(quoted_msg_id) = quoted_msg_id {
// For CallAccepted messages, retrieve the SDP (which is our answer)
let answer_sdp = context
.sql
.query_row_optional(
"SELECT sdp FROM calls WHERE msg_id=?",
(quoted_msg_id,),
|row| row.get::<_, Option<String>>(0),
)
.await?
.flatten()
.or_else(|| {
msg.param.get(Param::WebrtcAccepted).map(|s| s.to_string())
});
if let Some(answer_sdp) = answer_sdp {
headers.push((
"Chat-Webrtc-Accepted",
mail_builder::headers::raw::Raw::new(b_encode(&answer_sdp)).into(),
));
}
}
}
}
SystemMessage::CallEnded => {
headers.push((
@@ -1716,16 +1752,23 @@ impl MimeFactory {
);
}
if let Some(offer) = msg.param.get(Param::WebrtcRoom) {
headers.push((
"Chat-Webrtc-Room",
mail_builder::headers::raw::Raw::new(b_encode(offer)).into(),
));
} else if let Some(answer) = msg.param.get(Param::WebrtcAccepted) {
headers.push((
"Chat-Webrtc-Accepted",
mail_builder::headers::raw::Raw::new(b_encode(answer)).into(),
));
if msg.viewtype == Viewtype::Call {
// Get SDP offer from the message field (if being sent) or params (for old messages).
// For outgoing calls, we don't store the offer in the database, only in memory.
// For incoming calls that are stored, we could query the database, but we typically
// only render outgoing call messages where we use the call_sdp_offer field.
let offer_sdp = if let Some(ref offer) = msg.call_sdp_offer {
Some(offer.clone())
} else {
msg.param.get(Param::WebrtcRoom).map(|s| s.to_string())
};
if let Some(offer_sdp) = offer_sdp {
headers.push((
"Chat-Webrtc-Room",
mail_builder::headers::raw::Raw::new(b_encode(&offer_sdp)).into(),
));
}
}
if msg.viewtype == Viewtype::Voice

View File

@@ -1,7 +1,6 @@
use std::cmp;
use std::iter::{self, once};
use std::num::NonZeroUsize;
use std::sync::atomic::Ordering;
use anyhow::{Context as _, Error, Result, bail};
use async_channel::{self as channel, Receiver, Sender};
@@ -481,11 +480,10 @@ async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session)
}
}
let resync_requested = ctx.resync_request.swap(false, Ordering::Relaxed);
if resync_requested {
if let Ok(()) = imap.resync_request_receiver.try_recv() {
if let Err(err) = session.resync_folders(ctx).await {
warn!(ctx, "Failed to resync folders: {:#}.", err);
ctx.resync_request.store(true, Ordering::Relaxed);
imap.resync_request_sender.try_send(()).ok();
}
}

View File

@@ -921,6 +921,27 @@ pub async fn housekeeping(context: &Context) -> Result<()> {
.log_err(context)
.ok();
// Delete call SDPs for ended calls (older than 24 hours) or trashed calls.
// We clean up calls that ended more than 24 hours ago to protect privacy
// as SDPs contain IP addresses.
// The ON DELETE CASCADE foreign key handles orphaned entries automatically.
context
.sql
.execute(
"DELETE FROM calls WHERE msg_id IN (
SELECT calls.msg_id FROM calls
INNER JOIN msgs ON calls.msg_id = msgs.id
WHERE msgs.chat_id = ?
OR (calls.ended_timestamp IS NOT NULL
AND calls.ended_timestamp < ?)
)",
(DC_CHAT_ID_TRASH, time() - 86400),
)
.await
.context("Failed to delete old call SDPs")
.log_err(context)
.ok();
info!(context, "Housekeeping done.");
Ok(())
}

View File

@@ -1339,6 +1339,19 @@ CREATE INDEX gossip_timestamp_index ON gossip_timestamp (chat_id, fingerprint);
.await?;
}
inc_and_check(&mut migration_version, 139)?;
if dbversion < migration_version {
sql.execute_migration(
"CREATE TABLE calls(
msg_id INTEGER PRIMARY KEY REFERENCES msgs(id) ON DELETE CASCADE,
sdp TEXT NOT NULL,
ended_timestamp INTEGER
) STRICT;",
migration_version,
)
.await?;
}
let new_version = sql
.get_raw_config_int(VERSION_CFG)
.await?