mirror of
https://github.com/chatmail/core.git
synced 2026-04-19 14:36:29 +03:00
feat: key-contacts
This change introduces a new type of contacts
identified by their public key fingerprint
rather than an e-mail address.
Encrypted chats now stay encrypted
and unencrypted chats stay unencrypted.
For example, 1:1 chats with key-contacts
are encrypted and 1:1 chats with address-contacts
are unencrypted.
Groups that have a group ID are encrypted
and can only contain key-contacts
while groups that don't have a group ID ("adhoc groups")
are unencrypted and can only contain address-contacts.
JSON-RPC API `reset_contact_encryption` is removed.
Python API `Contact.reset_encryption` is removed.
"Group tracking plugin" in legacy Python API was removed because it
relied on parsing email addresses from system messages with regexps.
Co-authored-by: Hocuri <hocuri@gmx.de>
Co-authored-by: iequidoo <dgreshilov@gmail.com>
Co-authored-by: B. Petersen <r10s@b44t.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
//! # MIME message production.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
use std::io::Cursor;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -23,14 +23,14 @@ use crate::contact::{Contact, ContactId, Origin};
|
||||
use crate::context::Context;
|
||||
use crate::e2ee::EncryptHelper;
|
||||
use crate::ephemeral::Timer as EphemeralTimer;
|
||||
use crate::key::DcKey;
|
||||
use crate::key::self_fingerprint;
|
||||
use crate::key::{DcKey, SignedPublicKey};
|
||||
use crate::location;
|
||||
use crate::log::{info, warn};
|
||||
use crate::message::{self, Message, MsgId, Viewtype};
|
||||
use crate::mimeparser::{is_hidden, SystemMessage};
|
||||
use crate::param::Param;
|
||||
use crate::peer_channels::create_iroh_header;
|
||||
use crate::peerstate::Peerstate;
|
||||
use crate::simplify::escape_message_footer_marks;
|
||||
use crate::stock_str;
|
||||
use crate::tools::{
|
||||
@@ -88,6 +88,13 @@ pub struct MimeFactory {
|
||||
/// but `MimeFactory` is not responsible for this.
|
||||
recipients: Vec<String>,
|
||||
|
||||
/// Vector of pairs of recipient
|
||||
/// addresses and OpenPGP keys
|
||||
/// to use for encryption.
|
||||
///
|
||||
/// `None` if the message is not encrypted.
|
||||
encryption_keys: Option<Vec<(String, SignedPublicKey)>>,
|
||||
|
||||
/// Vector of pairs of recipient name and address that goes into the `To` field.
|
||||
///
|
||||
/// The list of actual message recipient addresses may be different,
|
||||
@@ -99,11 +106,18 @@ pub struct MimeFactory {
|
||||
/// Vector of pairs of past group member names and addresses.
|
||||
past_members: Vec<(String, String)>,
|
||||
|
||||
/// Fingerprints of the members in the same order as in the `to`
|
||||
/// followed by `past_members`.
|
||||
///
|
||||
/// If this is not empty, its length
|
||||
/// should be the sum of `to` and `past_members` length.
|
||||
member_fingerprints: Vec<String>,
|
||||
|
||||
/// Timestamps of the members in the same order as in the `to`
|
||||
/// followed by `past_members`.
|
||||
///
|
||||
/// If this is not empty, its length
|
||||
/// should be the sum of `recipients` and `past_members` length.
|
||||
/// should be the sum of `to` and `past_members` length.
|
||||
member_timestamps: Vec<i64>,
|
||||
|
||||
timestamp: i64,
|
||||
@@ -185,12 +199,24 @@ impl MimeFactory {
|
||||
let mut recipients = Vec::new();
|
||||
let mut to = Vec::new();
|
||||
let mut past_members = Vec::new();
|
||||
let mut member_fingerprints = Vec::new();
|
||||
let mut member_timestamps = Vec::new();
|
||||
let mut recipient_ids = HashSet::new();
|
||||
let mut req_mdn = false;
|
||||
|
||||
let encryption_keys;
|
||||
|
||||
let self_fingerprint = self_fingerprint(context).await?;
|
||||
|
||||
if chat.is_self_talk() {
|
||||
to.push((from_displayname.to_string(), from_addr.to_string()));
|
||||
|
||||
encryption_keys = if msg.param.get_bool(Param::ForcePlaintext).unwrap_or(false) {
|
||||
None
|
||||
} else {
|
||||
// Encrypt, but only to self.
|
||||
Some(Vec::new())
|
||||
};
|
||||
} else if chat.is_mailing_list() {
|
||||
let list_post = chat
|
||||
.param
|
||||
@@ -198,6 +224,9 @@ impl MimeFactory {
|
||||
.context("Can't write to mailinglist without ListPost param")?;
|
||||
to.push(("".to_string(), list_post.to_string()));
|
||||
recipients.push(list_post.to_string());
|
||||
|
||||
// Do not encrypt messages to mailing lists.
|
||||
encryption_keys = None;
|
||||
} else {
|
||||
let email_to_remove = if msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup {
|
||||
msg.param.get(Param::Arg)
|
||||
@@ -205,27 +234,59 @@ impl MimeFactory {
|
||||
None
|
||||
};
|
||||
|
||||
let is_encrypted = if msg
|
||||
.param
|
||||
.get_bool(Param::ForcePlaintext)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
false
|
||||
} else {
|
||||
msg.param.get_bool(Param::GuaranteeE2ee).unwrap_or_default()
|
||||
|| chat.is_encrypted(context).await?
|
||||
};
|
||||
|
||||
let mut keys = Vec::new();
|
||||
let mut missing_key_addresses = BTreeSet::new();
|
||||
context
|
||||
.sql
|
||||
.query_map(
|
||||
"SELECT c.authname, c.addr, c.id, cc.add_timestamp, cc.remove_timestamp
|
||||
"SELECT
|
||||
c.authname,
|
||||
c.addr,
|
||||
c.fingerprint,
|
||||
c.id,
|
||||
cc.add_timestamp,
|
||||
cc.remove_timestamp,
|
||||
k.public_key
|
||||
FROM chats_contacts cc
|
||||
LEFT JOIN contacts c ON cc.contact_id=c.id
|
||||
WHERE cc.chat_id=? AND (cc.contact_id>9 OR (cc.contact_id=1 AND ?))",
|
||||
LEFT JOIN public_keys k ON k.fingerprint=c.fingerprint
|
||||
WHERE cc.chat_id=?
|
||||
AND (cc.contact_id>9 OR (cc.contact_id=1 AND ?))",
|
||||
(msg.chat_id, chat.typ == Chattype::Group),
|
||||
|row| {
|
||||
let authname: String = row.get(0)?;
|
||||
let addr: String = row.get(1)?;
|
||||
let id: ContactId = row.get(2)?;
|
||||
let add_timestamp: i64 = row.get(3)?;
|
||||
let remove_timestamp: i64 = row.get(4)?;
|
||||
Ok((authname, addr, id, add_timestamp, remove_timestamp))
|
||||
let fingerprint: String = row.get(2)?;
|
||||
let id: ContactId = row.get(3)?;
|
||||
let add_timestamp: i64 = row.get(4)?;
|
||||
let remove_timestamp: i64 = row.get(5)?;
|
||||
let public_key_bytes_opt: Option<Vec<u8>> = row.get(6)?;
|
||||
Ok((authname, addr, fingerprint, id, add_timestamp, remove_timestamp, public_key_bytes_opt))
|
||||
},
|
||||
|rows| {
|
||||
let mut past_member_timestamps = Vec::new();
|
||||
let mut past_member_fingerprints = Vec::new();
|
||||
|
||||
for row in rows {
|
||||
let (authname, addr, id, add_timestamp, remove_timestamp) = row?;
|
||||
let (authname, addr, fingerprint, id, add_timestamp, remove_timestamp, public_key_bytes_opt) = row?;
|
||||
|
||||
let public_key_opt = if let Some(public_key_bytes) = &public_key_bytes_opt {
|
||||
Some(SignedPublicKey::from_slice(public_key_bytes)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let addr = if id == ContactId::SELF {
|
||||
from_addr.to_string()
|
||||
} else {
|
||||
@@ -237,13 +298,34 @@ impl MimeFactory {
|
||||
};
|
||||
if add_timestamp >= remove_timestamp {
|
||||
if !recipients_contain_addr(&to, &addr) {
|
||||
recipients.push(addr.clone());
|
||||
if id != ContactId::SELF {
|
||||
recipients.push(addr.clone());
|
||||
}
|
||||
if !undisclosed_recipients {
|
||||
to.push((name, addr));
|
||||
to.push((name, addr.clone()));
|
||||
|
||||
if is_encrypted {
|
||||
if !fingerprint.is_empty() {
|
||||
member_fingerprints.push(fingerprint);
|
||||
} else if id == ContactId::SELF {
|
||||
member_fingerprints.push(self_fingerprint.to_string());
|
||||
} else {
|
||||
debug_assert!(member_fingerprints.is_empty(), "If some past member is a key-contact, all other past members should be key-contacts too");
|
||||
}
|
||||
}
|
||||
member_timestamps.push(add_timestamp);
|
||||
}
|
||||
}
|
||||
recipient_ids.insert(id);
|
||||
|
||||
if let Some(public_key) = public_key_opt {
|
||||
keys.push((addr.clone(), public_key))
|
||||
} else if id != ContactId::SELF {
|
||||
missing_key_addresses.insert(addr.clone());
|
||||
if is_encrypted {
|
||||
warn!(context, "Missing key for {addr}");
|
||||
}
|
||||
}
|
||||
} else if remove_timestamp.saturating_add(60 * 24 * 3600) > now {
|
||||
// Row is a tombstone,
|
||||
// member is not actually part of the group.
|
||||
@@ -253,27 +335,57 @@ impl MimeFactory {
|
||||
// This is a "member removed" message,
|
||||
// we need to notify removed member
|
||||
// that it was removed.
|
||||
recipients.push(addr.clone());
|
||||
if id != ContactId::SELF {
|
||||
recipients.push(addr.clone());
|
||||
}
|
||||
|
||||
if let Some(public_key) = public_key_opt {
|
||||
keys.push((addr.clone(), public_key))
|
||||
} else if id != ContactId::SELF {
|
||||
missing_key_addresses.insert(addr.clone());
|
||||
if is_encrypted {
|
||||
warn!(context, "Missing key for {addr}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !undisclosed_recipients {
|
||||
past_members.push((name, addr));
|
||||
past_members.push((name, addr.clone()));
|
||||
past_member_timestamps.push(remove_timestamp);
|
||||
|
||||
if is_encrypted {
|
||||
if !fingerprint.is_empty() {
|
||||
past_member_fingerprints.push(fingerprint);
|
||||
} else if id == ContactId::SELF {
|
||||
// It's fine to have self in past members
|
||||
// if we are leaving the group.
|
||||
past_member_fingerprints.push(self_fingerprint.to_string());
|
||||
} else {
|
||||
debug_assert!(past_member_fingerprints.is_empty(), "If some past member is a key-contact, all other past members should be key-contacts too");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug_assert!(member_timestamps.len() >= to.len());
|
||||
debug_assert!(member_fingerprints.is_empty() || member_fingerprints.len() >= to.len());
|
||||
|
||||
if to.len() > 1 {
|
||||
if let Some(position) = to.iter().position(|(_, x)| x == &from_addr) {
|
||||
to.remove(position);
|
||||
member_timestamps.remove(position);
|
||||
if is_encrypted {
|
||||
member_fingerprints.remove(position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
member_timestamps.extend(past_member_timestamps);
|
||||
if is_encrypted {
|
||||
member_fingerprints.extend(past_member_fingerprints);
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
@@ -287,7 +399,26 @@ impl MimeFactory {
|
||||
{
|
||||
req_mdn = true;
|
||||
}
|
||||
|
||||
encryption_keys = if !is_encrypted {
|
||||
None
|
||||
} else {
|
||||
if keys.is_empty() && !recipients.is_empty() {
|
||||
bail!(
|
||||
"No recipient keys are available, cannot encrypt to {:?}.",
|
||||
recipients
|
||||
);
|
||||
}
|
||||
|
||||
// Remove recipients for which the key is missing.
|
||||
if !missing_key_addresses.is_empty() {
|
||||
recipients.retain(|addr| !missing_key_addresses.contains(addr));
|
||||
}
|
||||
|
||||
Some(keys)
|
||||
};
|
||||
}
|
||||
|
||||
let (in_reply_to, references) = context
|
||||
.sql
|
||||
.query_row(
|
||||
@@ -320,14 +451,17 @@ impl MimeFactory {
|
||||
member_timestamps.is_empty()
|
||||
|| to.len() + past_members.len() == member_timestamps.len()
|
||||
);
|
||||
|
||||
let factory = MimeFactory {
|
||||
from_addr,
|
||||
from_displayname,
|
||||
sender_displayname,
|
||||
selfstatus,
|
||||
recipients,
|
||||
encryption_keys,
|
||||
to,
|
||||
past_members,
|
||||
member_fingerprints,
|
||||
member_timestamps,
|
||||
timestamp: msg.timestamp_sort,
|
||||
loaded: Loaded::Message { msg, chat },
|
||||
@@ -351,14 +485,27 @@ impl MimeFactory {
|
||||
let from_addr = context.get_primary_self_addr().await?;
|
||||
let timestamp = create_smeared_timestamp(context);
|
||||
|
||||
let addr = contact.get_addr().to_string();
|
||||
let encryption_keys = if contact.is_key_contact() {
|
||||
if let Some(key) = contact.public_key(context).await? {
|
||||
Some(vec![(addr.clone(), key)])
|
||||
} else {
|
||||
Some(Vec::new())
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let res = MimeFactory {
|
||||
from_addr,
|
||||
from_displayname: "".to_string(),
|
||||
sender_displayname: None,
|
||||
selfstatus: "".to_string(),
|
||||
recipients: vec![contact.get_addr().to_string()],
|
||||
recipients: vec![addr],
|
||||
encryption_keys,
|
||||
to: vec![("".to_string(), contact.get_addr().to_string())],
|
||||
past_members: vec![],
|
||||
member_fingerprints: vec![],
|
||||
member_timestamps: vec![],
|
||||
timestamp,
|
||||
loaded: Loaded::Mdn {
|
||||
@@ -376,57 +523,6 @@ impl MimeFactory {
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
async fn peerstates_for_recipients(
|
||||
&self,
|
||||
context: &Context,
|
||||
) -> Result<Vec<(Option<Peerstate>, String)>> {
|
||||
let self_addr = context.get_primary_self_addr().await?;
|
||||
|
||||
let mut res = Vec::new();
|
||||
for addr in self.recipients.iter().filter(|&addr| *addr != self_addr) {
|
||||
res.push((Peerstate::from_addr(context, addr).await?, addr.clone()));
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
fn is_e2ee_guaranteed(&self) -> bool {
|
||||
match &self.loaded {
|
||||
Loaded::Message { chat, msg } => {
|
||||
!msg.param
|
||||
.get_bool(Param::ForcePlaintext)
|
||||
.unwrap_or_default()
|
||||
&& (chat.is_protected()
|
||||
|| msg.param.get_bool(Param::GuaranteeE2ee).unwrap_or_default())
|
||||
}
|
||||
Loaded::Mdn { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn verified(&self) -> bool {
|
||||
match &self.loaded {
|
||||
Loaded::Message { chat, msg } => {
|
||||
chat.is_self_talk() ||
|
||||
// Securejoin messages are supposed to verify a key.
|
||||
// In order to do this, it is necessary that they can be sent
|
||||
// to a key that is not yet verified.
|
||||
// This has to work independently of whether the chat is protected right now.
|
||||
chat.is_protected() && msg.get_info_type() != SystemMessage::SecurejoinMessage
|
||||
}
|
||||
Loaded::Mdn { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn should_force_plaintext(&self) -> bool {
|
||||
match &self.loaded {
|
||||
Loaded::Message { msg, .. } => msg
|
||||
.param
|
||||
.get_bool(Param::ForcePlaintext)
|
||||
.unwrap_or_default(),
|
||||
Loaded::Mdn { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn should_skip_autocrypt(&self) -> bool {
|
||||
match &self.loaded {
|
||||
Loaded::Message { msg, .. } => {
|
||||
@@ -602,21 +698,35 @@ impl MimeFactory {
|
||||
}
|
||||
|
||||
if let Loaded::Message { chat, .. } = &self.loaded {
|
||||
if chat.typ == Chattype::Group
|
||||
&& !self.member_timestamps.is_empty()
|
||||
&& !chat.member_list_is_stale(context).await?
|
||||
{
|
||||
headers.push((
|
||||
"Chat-Group-Member-Timestamps",
|
||||
mail_builder::headers::raw::Raw::new(
|
||||
self.member_timestamps
|
||||
.iter()
|
||||
.map(|ts| ts.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join(" "),
|
||||
)
|
||||
.into(),
|
||||
));
|
||||
if chat.typ == Chattype::Group {
|
||||
if !self.member_timestamps.is_empty() && !chat.member_list_is_stale(context).await?
|
||||
{
|
||||
headers.push((
|
||||
"Chat-Group-Member-Timestamps",
|
||||
mail_builder::headers::raw::Raw::new(
|
||||
self.member_timestamps
|
||||
.iter()
|
||||
.map(|ts| ts.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join(" "),
|
||||
)
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
if !self.member_fingerprints.is_empty() {
|
||||
headers.push((
|
||||
"Chat-Group-Member-Fpr",
|
||||
mail_builder::headers::raw::Raw::new(
|
||||
self.member_fingerprints
|
||||
.iter()
|
||||
.map(|fp| fp.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join(" "),
|
||||
)
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,10 +837,8 @@ impl MimeFactory {
|
||||
));
|
||||
}
|
||||
|
||||
let verified = self.verified();
|
||||
let grpimage = self.grpimage();
|
||||
let skip_autocrypt = self.should_skip_autocrypt();
|
||||
let e2ee_guaranteed = self.is_e2ee_guaranteed();
|
||||
let encrypt_helper = EncryptHelper::new(context).await?;
|
||||
|
||||
if !skip_autocrypt {
|
||||
@@ -742,6 +850,8 @@ impl MimeFactory {
|
||||
));
|
||||
}
|
||||
|
||||
let is_encrypted = self.encryption_keys.is_some();
|
||||
|
||||
// Add ephemeral timer for non-MDN messages.
|
||||
// For MDNs it does not matter because they are not visible
|
||||
// and ignored by the receiver.
|
||||
@@ -755,9 +865,6 @@ impl MimeFactory {
|
||||
}
|
||||
}
|
||||
|
||||
let peerstates = self.peerstates_for_recipients(context).await?;
|
||||
let is_encrypted = !self.should_force_plaintext()
|
||||
&& (e2ee_guaranteed || encrypt_helper.should_encrypt(context, &peerstates).await?);
|
||||
let is_securejoin_message = if let Loaded::Message { msg, .. } = &self.loaded {
|
||||
msg.param.get_cmd() == SystemMessage::SecurejoinMessage
|
||||
} else {
|
||||
@@ -904,7 +1011,7 @@ impl MimeFactory {
|
||||
}
|
||||
}
|
||||
|
||||
let outer_message = if is_encrypted {
|
||||
let outer_message = if let Some(encryption_keys) = self.encryption_keys {
|
||||
// Store protected headers in the inner message.
|
||||
let message = protected_headers
|
||||
.into_iter()
|
||||
@@ -921,7 +1028,7 @@ impl MimeFactory {
|
||||
|
||||
// Add gossip headers in chats with multiple recipients
|
||||
let multiple_recipients =
|
||||
peerstates.len() > 1 || context.get_config_bool(Config::BccSelf).await?;
|
||||
encryption_keys.len() > 1 || context.get_config_bool(Config::BccSelf).await?;
|
||||
|
||||
let gossip_period = context.get_config_i64(Config::GossipPeriod).await?;
|
||||
let now = time();
|
||||
@@ -929,11 +1036,7 @@ impl MimeFactory {
|
||||
match &self.loaded {
|
||||
Loaded::Message { chat, msg } => {
|
||||
if chat.typ != Chattype::Broadcast {
|
||||
for peerstate in peerstates.iter().filter_map(|(state, _)| state.as_ref()) {
|
||||
let Some(key) = peerstate.peek_key(verified) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for (addr, key) in &encryption_keys {
|
||||
let fingerprint = key.dc_fingerprint().hex();
|
||||
let cmd = msg.param.get_cmd();
|
||||
let should_do_gossip = cmd == SystemMessage::MemberAddedToGroup
|
||||
@@ -965,7 +1068,7 @@ impl MimeFactory {
|
||||
}
|
||||
|
||||
let header = Aheader::new(
|
||||
peerstate.addr.clone(),
|
||||
addr.clone(),
|
||||
key.clone(),
|
||||
// Autocrypt 1.1.0 specification says that
|
||||
// `prefer-encrypt` attribute SHOULD NOT be included.
|
||||
@@ -1015,8 +1118,10 @@ impl MimeFactory {
|
||||
Loaded::Mdn { .. } => true,
|
||||
};
|
||||
|
||||
let (encryption_keyring, missing_key_addresses) =
|
||||
encrypt_helper.encryption_keyring(context, verified, &peerstates)?;
|
||||
// Encrypt to self unconditionally,
|
||||
// even for a single-device setup.
|
||||
let mut encryption_keyring = vec![encrypt_helper.public_key.clone()];
|
||||
encryption_keyring.extend(encryption_keys.iter().map(|(_addr, key)| (*key).clone()));
|
||||
|
||||
// XXX: additional newline is needed
|
||||
// to pass filtermail at
|
||||
@@ -1026,12 +1131,6 @@ impl MimeFactory {
|
||||
.await?
|
||||
+ "\n";
|
||||
|
||||
// Remove recipients for which the key is missing.
|
||||
if !missing_key_addresses.is_empty() {
|
||||
self.recipients
|
||||
.retain(|addr| !missing_key_addresses.contains(addr));
|
||||
}
|
||||
|
||||
// Set the appropriate Content-Type for the outer message
|
||||
MimePart::new(
|
||||
"multipart/encrypted; protocol=\"application/pgp-encrypted\"",
|
||||
@@ -1257,6 +1356,8 @@ impl MimeFactory {
|
||||
}
|
||||
}
|
||||
SystemMessage::MemberAddedToGroup => {
|
||||
// TODO: lookup the contact by ID rather than email address.
|
||||
// We are adding key-contacts, the cannot be looked up by address.
|
||||
let email_to_add = msg.param.get(Param::Arg).unwrap_or_default();
|
||||
placeholdertext =
|
||||
Some(stock_str::msg_add_member_remote(context, email_to_add).await);
|
||||
@@ -1561,7 +1662,7 @@ impl MimeFactory {
|
||||
|
||||
// we do not piggyback sync-files to other self-sent-messages
|
||||
// to not risk files becoming too larger and being skipped by download-on-demand.
|
||||
if command == SystemMessage::MultiDeviceSync && self.is_e2ee_guaranteed() {
|
||||
if command == SystemMessage::MultiDeviceSync {
|
||||
let json = msg.param.get(Param::Arg).unwrap_or_default();
|
||||
let ids = msg.param.get(Param::Arg2).unwrap_or_default();
|
||||
parts.push(context.build_sync_part(json.to_string()));
|
||||
|
||||
Reference in New Issue
Block a user