mirror of
https://github.com/chatmail/core.git
synced 2026-04-17 13:36:30 +03:00
Compare commits
16 Commits
v2.25.0
...
copilot/cr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac78f76602 | ||
|
|
94c373368c | ||
|
|
76db7853ff | ||
|
|
d946979b88 | ||
|
|
12b21c6cb3 | ||
|
|
1a04fc5db3 | ||
|
|
55cf576e13 | ||
|
|
c6a871c64a | ||
|
|
19be18dcbf | ||
|
|
c556b07380 | ||
|
|
abece73db1 | ||
|
|
7fef812b1e | ||
|
|
5f174ceaf2 | ||
|
|
06b038ab5d | ||
|
|
b20da3cb0e | ||
|
|
a3328ea2de |
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
109
src/calls.rs
109
src/calls.rs
@@ -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,
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
51
src/chat.rs
51
src/chat.rs
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
14
src/imap.rs
14
src/imap.rs
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
21
src/sql.rs
21
src/sql.rs
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
Reference in New Issue
Block a user