Skip to content
15 changes: 15 additions & 0 deletions deltachat-jsonrpc/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1080,6 +1080,21 @@ impl CommandApi {
.map(|id| id.to_u32())
}

/// Create a new encrypted group chat with an admin.
///
/// Similar to [`Self::create_group_chat`], but only the creator (admin) can add/remove
/// members or change the group name, description, or avatar.
/// Other members can send messages and leave the group.
///
/// The admin is identified by their PGP key fingerprint, which is encoded in the group ID.
/// The `groupAdminId` field in [`FullChat`] will be set to the admin's contact ID.
async fn create_group_with_admin(&self, account_id: u32, name: String) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
chat::create_group_with_admin(&ctx, &name)
.await
.map(|id| id.to_u32())
}

/// Deprecated 2025-07 in favor of create_broadcast().
async fn create_broadcast_list(&self, account_id: u32) -> Result<u32> {
self.create_broadcast(account_id, "Channel".to_string())
Expand Down
12 changes: 11 additions & 1 deletion deltachat-jsonrpc/src/api/types/chat.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::time::{Duration, SystemTime};

use anyhow::{bail, Context as _, Result};
use deltachat::chat::{self, get_chat_contacts, get_past_chat_contacts, ChatVisibility};
use deltachat::chat::{self, get_admin_contact_id, get_chat_contacts, get_past_chat_contacts, ChatVisibility};
use deltachat::chat::{Chat, ChatId};
use deltachat::constants::Chattype;
use deltachat::contact::{Contact, ContactId};
Expand Down Expand Up @@ -71,6 +71,11 @@ pub struct FullChat {
can_send: bool,
was_seen_recently: bool,
mailing_list_address: Option<String>,
/// Contact ID of the group admin for admin-controlled groups, or `null` for regular groups.
///
/// When non-null, only the admin can add/remove members or change the group name,
/// description, or avatar. Non-admin members can send messages and leave the group.
group_admin_id: Option<u32>,
}

impl FullChat {
Expand Down Expand Up @@ -106,6 +111,10 @@ impl FullChat {

let mailing_list_address = chat.get_mailinglist_addr().map(|s| s.to_string());

let group_admin_id = get_admin_contact_id(context, &chat.grpid)
.await?
.map(|id| id.to_u32());

Ok(FullChat {
id: chat_id,
name: chat.name.clone(),
Expand All @@ -128,6 +137,7 @@ impl FullChat {
can_send,
was_seen_recently,
mailing_list_address,
group_admin_id,
})
}
}
Expand Down
111 changes: 111 additions & 0 deletions src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,27 @@ use crate::webxdc::StatusUpdateSerial;

pub(crate) const PARAM_BROADCAST_SECRET: Param = Param::Arg3;

/// Separator between the admin's fingerprint and the random group ID in admin groups.
///
/// Must not be a URL-safe base64 character (`A-Za-z0-9-_`).
/// Fingerprints are uppercase hex (`A-F0-9`), so `.` is unambiguous in both positions.
pub(crate) const ADMIN_GROUP_ID_SEPARATOR: char = '.';

/// Returns the admin fingerprint if this is an admin group (grpid contains `FINGERPRINT.GRPID`).
pub(crate) fn admin_group_fingerprint(grpid: &str) -> Option<&str> {
grpid.split_once(ADMIN_GROUP_ID_SEPARATOR).map(|(fpr, _)| fpr)
}

/// Returns the base group ID (the random part after the fingerprint), for use in QR codes.
///
/// For regular groups, returns the full grpid unchanged.
pub(crate) fn admin_group_base_id(grpid: &str) -> &str {
grpid
.split_once(ADMIN_GROUP_ID_SEPARATOR)
.map(|(_, id)| id)
.unwrap_or(grpid)
}

/// An chat item, such as a message or a marker.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum ChatItem {
Expand Down Expand Up @@ -3573,6 +3594,54 @@ pub async fn create_group_unencrypted(context: &Context, name: &str) -> Result<C
create_group_ex(context, Sync, String::new(), name).await
}

/// Creates an encrypted group chat with an admin.
///
/// The group ID is composed of the creator's fingerprint and a random ID separated by
/// [`ADMIN_GROUP_ID_SEPARATOR`] (e.g., `ABCDEF0123456789...ABCDEF0123456789.RANDOMBASE64`).
/// The random part is generated by [`create_id`], which produces URL-safe base64 characters
/// (`A-Za-z0-9-_`) that never contain [`ADMIN_GROUP_ID_SEPARATOR`], keeping the format unambiguous.
/// Only the admin (creator) can add/remove members or change the group name, description,
/// or avatar. Other members can send messages and leave the group.
pub async fn create_group_with_admin(context: &Context, name: &str) -> Result<ChatId> {
let fingerprint = self_fingerprint(context).await?;
let base_grpid = create_id();
let grpid = format!("{fingerprint}{ADMIN_GROUP_ID_SEPARATOR}{base_grpid}");
create_group_ex(context, Sync, grpid, name).await
}

/// Returns the contact ID of the group admin for an admin group, or `None` for regular groups.
///
/// For admin groups (where `grpid` is `FINGERPRINT.GRPID`), this looks up the contact
/// whose key fingerprint matches the fingerprint in the group ID.
/// Returns [`ContactId::SELF`] if the admin is the current user.
pub async fn get_admin_contact_id(
context: &Context,
grpid: &str,
) -> Result<Option<ContactId>> {
let Some(admin_fpr) = admin_group_fingerprint(grpid) else {
return Ok(None);
};

// Check self first (cheaper and handles the case where the contact is not yet in the DB).
use crate::key::self_fingerprint_opt;
if let Some(self_fpr) = self_fingerprint_opt(context).await? {
if self_fpr == admin_fpr {
return Ok(Some(ContactId::SELF));
}
}

// `admin_group_fingerprint` guarantees admin_fpr is non-empty.
let contact_id = context
.sql
.query_row_optional(
"SELECT id FROM contacts WHERE fingerprint=?",
(admin_fpr,),
|row| row.get::<_, ContactId>(0),
)
.await?;
Ok(contact_id)
}

/// Creates a group chat.
///
/// * `sync` - Whether a multi-device synchronization message should be sent. Ignored for
Expand Down Expand Up @@ -3918,6 +3987,16 @@ pub(crate) async fn add_contact_to_chat_ex(
),
}

if let Some(admin_fpr) = admin_group_fingerprint(&chat.grpid) {
if !from_handshake {
let self_fpr = self_fingerprint(context).await?;
ensure!(
self_fpr == admin_fpr,
"Only the group admin can add members to this group"
);
}
}

if !chat.is_self_in_chat(context).await? {
context.emit_event(EventType::ErrorSelfNotInGroup(
"Cannot add contact to group; self not in group.".into(),
Expand Down Expand Up @@ -4146,6 +4225,16 @@ pub async fn remove_contact_from_chat(
bail!("{err_msg}");
}

if let Some(admin_fpr) = admin_group_fingerprint(&chat.grpid) {
if contact_id != ContactId::SELF {
let self_fpr = self_fingerprint(context).await?;
ensure!(
self_fpr == admin_fpr,
"Only the group admin can remove other members from this group"
);
}
}

let mut sync = Nosync;

if chat.is_promoted() && chat.typ != Chattype::OutBroadcast {
Expand Down Expand Up @@ -4257,6 +4346,14 @@ async fn set_chat_description_ex(
bail!("Cannot set chat description; self not in group");
}

if let Some(admin_fpr) = admin_group_fingerprint(&chat.grpid) {
let self_fpr = self_fingerprint(context).await?;
ensure!(
self_fpr == admin_fpr,
"Only the group admin can change the description of this group"
);
}

let old_description = get_chat_description(context, chat_id).await?;
if old_description == new_description {
return Ok(());
Expand Down Expand Up @@ -4345,6 +4442,13 @@ async fn rename_ex(
"Cannot set chat name; self not in group".into(),
));
} else {
if let Some(admin_fpr) = admin_group_fingerprint(&chat.grpid) {
let self_fpr = self_fingerprint(context).await?;
ensure!(
self_fpr == admin_fpr,
"Only the group admin can rename this group"
);
}
context
.sql
.execute(
Expand Down Expand Up @@ -4416,6 +4520,13 @@ pub async fn set_chat_profile_image(
));
bail!("Failed to set profile image");
}
if let Some(admin_fpr) = admin_group_fingerprint(&chat.grpid) {
let self_fpr = self_fingerprint(context).await?;
ensure!(
self_fpr == admin_fpr,
"Only the group admin can change the profile image of this group"
);
}
let mut msg = Message::new(Viewtype::Text);
msg.param
.set_int(Param::Cmd, SystemMessage::GroupImageChanged as i32);
Expand Down
6 changes: 4 additions & 2 deletions src/mimeparser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ use crate::param::{Param, Params};
use crate::simplify::{SimplifiedText, simplify};
use crate::sync::SyncItems;
use crate::tools::{
get_filemeta, parse_receive_headers, smeared_time, time, truncate_msg_text, validate_id,
get_filemeta, parse_receive_headers, smeared_time, time, truncate_msg_text, validate_group_id,
};
use crate::{chatlist_events, location, tools};

Expand Down Expand Up @@ -1094,9 +1094,11 @@ impl MimeMessage {
}

/// Returns `Chat-Group-ID` header value if it is a valid group ID.
///
/// Accepts both regular group IDs and admin group IDs (`FINGERPRINT.base_grpid`).
pub fn get_chat_group_id(&self) -> Option<&str> {
self.get_header(HeaderDef::ChatGroupId)
.filter(|s| validate_id(s))
.filter(|s| validate_group_id(s))
}

async fn parse_mime_recursive<'a>(
Expand Down
23 changes: 19 additions & 4 deletions src/qr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use crate::net::http::post_empty;
use crate::net::proxy::{DEFAULT_SOCKS_PORT, ProxyConfig};
use crate::token;
use crate::tools::{time, validate_id};
use crate::chat::{ADMIN_GROUP_ID_SEPARATOR};

const OPENPGP4FPR_SCHEME: &str = "OPENPGP4FPR:"; // yes: uppercase
const IDELTACHAT_SCHEME: &str = "https://i.delta.chat/#";
Expand Down Expand Up @@ -502,14 +503,28 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
.get("s")
.filter(|&s| validate_id(s))
.map(|s| s.to_string());
let grpid = param
.get("x")
.filter(|&s| validate_id(s))
.map(|s| s.to_string());

let grpname = decode_name(&param, "g")?;
let admin_grpname = decode_name(&param, "z")?;
let broadcast_name = decode_name(&param, "b")?;

// For admin groups, reconstruct the full grpid as FINGERPRINT.base_grpid.
// The base_grpid comes from x= and the fingerprint is already present in the QR code.
let grpid = if admin_grpname.is_some() {
param
.get("x")
.filter(|&s| validate_id(s))
.map(|base_id| format!("{}{ADMIN_GROUP_ID_SEPARATOR}{base_id}", fingerprint.hex()))
} else {
param
.get("x")
.filter(|&s| validate_id(s))
.map(|s| s.to_string())
};

// Use admin_grpname as grpname when present (admin group).
let grpname = grpname.or(admin_grpname);

let mut is_v3 = param.get("v") == Some(&"3");

if authcode.is_some() && invitenumber.is_none() {
Expand Down
Loading