From bcadd0cd5c49d5d0e94b191623e8dd5a4b2aa6d8 Mon Sep 17 00:00:00 2001 From: bjoern Date: Thu, 30 Sep 2021 13:56:05 +0200 Subject: [PATCH] broadcasts (#2707) * add Chattype::Broadcast and create_broadcast_list() * do not disclose recipients for broadcasts * allow sending/add-/remove-member for broadcast * set broadcast subject same as for one-to-one chats * broadcast-recipient-list does not include SELF * use special icon for broadcast groups * generate initial broadcast names * make clippy happy * send BCC message unencrypted to avoid unexpected disclosing; encryption is opportunistic anyway. if we have 'protected chats' at some point, we can think that over. * reword 'To:'-group * simplify can-send-check * add broadcast tests * tweak comments * Update deltachat-ffi/deltachat.h Co-authored-by: Hocuri * change name of can_edit() to is_self_in_chat() Co-authored-by: Hocuri --- assets/icon-broadcast.png | Bin 0 -> 4274 bytes assets/icon-broadcast.svg | 149 ++++++++++++++++++++++++ deltachat-ffi/deltachat.h | 47 ++++++++ deltachat-ffi/src/lib.rs | 13 +++ examples/repl/cmdline.rs | 6 + examples/repl/main.rs | 5 +- src/chat.rs | 238 +++++++++++++++++++++++++++++--------- src/chatlist.rs | 2 +- src/constants.rs | 2 + src/message.rs | 4 +- src/mimefactory.rs | 52 ++++++--- src/stock_str.rs | 9 ++ src/summary.rs | 2 +- 13 files changed, 449 insertions(+), 80 deletions(-) create mode 100644 assets/icon-broadcast.png create mode 100644 assets/icon-broadcast.svg diff --git a/assets/icon-broadcast.png b/assets/icon-broadcast.png new file mode 100644 index 0000000000000000000000000000000000000000..aa761885eb7d99662335514849a7f6fc6816b2d0 GIT binary patch literal 4274 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 G67^rW53!v9 literal 0 HcmV?d00001 diff --git a/assets/icon-broadcast.svg b/assets/icon-broadcast.svg new file mode 100644 index 000000000..733e48c1d --- /dev/null +++ b/assets/icon-broadcast.svg @@ -0,0 +1,149 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 9848fc70d..7ea527518 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -1299,6 +1299,8 @@ void dc_accept_chat (dc_context_t* context, uint32_t ch * explicitly as it may happen that oneself gets removed from a still existing * group * + * - for broadcasts, all recipients are returned, DC_CONTACT_ID_SELF is not included + * * - for mailing lists, the behavior is not documented currently, we will decide on that later. * for now, the UI should not show the list for mailing lists. * (we do not know all members and there is not always a global mailing list address, @@ -1408,6 +1410,36 @@ dc_chat_t* dc_get_chat (dc_context_t* context, uint32_t ch uint32_t dc_create_group_chat (dc_context_t* context, int protect, const char* name); +/** + * Create a new broadcast list. + * + * Broadcast lists are similar to groups on the sending device, + * however, recipients get the messages in normal one-to-one chats + * and will not be aware of other members. + * + * Replies to broadcasts go only to the sender + * and not to all broadcast recipients. + * Moreover, replies will not appear in the broadcast list + * but in the one-to-one chat with the person answering. + * + * The name and the image of the broadcast list is set automatically + * and is visible to the sender only. + * Not asking for these data allows more focused creation + * and we bypass the question who will get which data. + * Also, many users will have at most one broadcast list + * so, a generic name and image is sufficient at the first place. + * + * Later on, however, the name can be changed using dc_set_chat_name(). + * The image cannot be changed to have a unique, recognizable icon in the chat lists. + * All in all, this is also what other messengers are doing here. + * + * @memberof dc_context_t + * @param context The context object. + * @return The chat ID of the new broadcast list, 0 on errors. + */ +uint32_t dc_create_broadcast_list (dc_context_t* context); + + /** * Check if a given contact ID is a member of a group chat. * @@ -3044,6 +3076,11 @@ uint32_t dc_chat_get_id (const dc_chat_t* chat); * and cannot be changed using this api. * moreover, for now, mailist lists are read-only. * + * - @ref DC_CHAT_TYPE_BROADCAST - a broadcast list, + * the recipients will get messages in a one-to-one chats and + * the sender will get answers in a one-to-one as well. + * chats_contacts contain all recipients but DC_CONTACT_ID_SELF + * * @memberof dc_chat_t * @param chat The chat object. * @return Chat type. @@ -4744,6 +4781,11 @@ int64_t dc_lot_get_timestamp (const dc_lot_t* lot); */ #define DC_CHAT_TYPE_MAILINGLIST 140 +/** + * A broadcast list. See dc_chat_get_type() for details. + */ +#define DC_CHAT_TYPE_BROADCAST 160 + /** * @} */ @@ -5967,6 +6009,11 @@ void dc_event_unref(dc_event_t* event); /// Used as a subtitle in quota context; can be plural always. #define DC_STR_MESSAGES 114 +/// "Broadcast List" +/// +/// Used as the default name for broadcast lists; a number may be added. +#define DC_STR_BROADCAST_LIST 115 + /** * @} */ diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index e66c5da30..c000a174c 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -1330,6 +1330,19 @@ pub unsafe extern "C" fn dc_create_group_chat( }) } +#[no_mangle] +pub unsafe extern "C" fn dc_create_broadcast_list(context: *mut dc_context_t) -> u32 { + if context.is_null() { + eprintln!("ignoring careless call to dc_create_broadcast_list()"); + return 0; + } + let ctx = &*context; + block_on(chat::create_broadcast_list(ctx)) + .log_err(ctx, "Failed to create broadcast list") + .map(|id| id.to_u32()) + .unwrap_or(0) +} + #[no_mangle] pub unsafe extern "C" fn dc_is_contact_in_chat( context: *mut dc_context_t, diff --git a/examples/repl/cmdline.rs b/examples/repl/cmdline.rs index 23ea29b2b..4556e84db 100644 --- a/examples/repl/cmdline.rs +++ b/examples/repl/cmdline.rs @@ -370,6 +370,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu chat [|0]\n\ createchat \n\ creategroup \n\ + createbroadcast\n\ createprotected \n\ addmember \n\ removemember \n\ @@ -714,6 +715,11 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu println!("Group#{} created successfully.", chat_id); } + "createbroadcast" => { + let chat_id = chat::create_broadcast_list(&context).await?; + + println!("Broadcast#{} created successfully.", chat_id); + } "createprotected" => { ensure!(!arg1.is_empty(), "Argument missing."); let chat_id = diff --git a/examples/repl/main.rs b/examples/repl/main.rs index 6c7622419..da6f4e957 100644 --- a/examples/repl/main.rs +++ b/examples/repl/main.rs @@ -167,13 +167,14 @@ const DB_COMMANDS: [&str; 10] = [ "housekeeping", ]; -const CHAT_COMMANDS: [&str; 33] = [ +const CHAT_COMMANDS: [&str; 34] = [ "listchats", "listarchived", "chat", "createchat", "creategroup", - "createverified", + "createbroadcast", + "createprotected", "addmember", "removemember", "groupname", diff --git a/src/chat.rs b/src/chat.rs index 5e974633e..b3f2fcc5e 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -271,7 +271,9 @@ impl ChatId { let chat = Chat::load_from_db(context, self).await?; match chat.typ { - Chattype::Undefined => bail!("Can't block chat of undefined chattype"), + Chattype::Undefined | Chattype::Broadcast => { + bail!("Can't block chat of type {:?}", chat.typ) + } Chattype::Single => { for contact_id in get_chat_contacts(context, self).await? { if contact_id != DC_CONTACT_ID_SELF { @@ -311,7 +313,7 @@ impl ChatId { match chat.typ { Chattype::Undefined => bail!("Can't accept chat of undefined chattype"), - Chattype::Single | Chattype::Group => { + Chattype::Single | Chattype::Group | Chattype::Broadcast => { // User has "created a chat" with all these contacts. // // Previously accepting a chat literally created a chat because unaccepted chats @@ -355,7 +357,7 @@ impl ChatId { match protect { ProtectionStatus::Protected => match chat.typ { - Chattype::Single | Chattype::Group => { + Chattype::Single | Chattype::Group | Chattype::Broadcast => { let contact_ids = get_chat_contacts(context, self).await?; for contact_id in contact_ids.into_iter() { let contact = Contact::get_by_id(context, contact_id).await?; @@ -980,8 +982,18 @@ impl Chat { && !self.is_device_talk() && !self.is_mailing_list() && !self.is_contact_request() - && (self.typ == Chattype::Single - || is_contact_in_chat(context, self.id, DC_CONTACT_ID_SELF).await?)) + && self.is_self_in_chat(context).await?) + } + + /// Checks if the user is part of a chat + /// and has basically the permissions to edit the chat therefore. + /// The function does not check if the chat type allows editing of concrete elements. + pub(crate) async fn is_self_in_chat(&self, context: &Context) -> Result { + match self.typ { + Chattype::Single | Chattype::Broadcast | Chattype::Mailinglist => Ok(true), + Chattype::Group => is_contact_in_chat(context, self.id, DC_CONTACT_ID_SELF).await, + Chattype::Undefined => Ok(false), + } } pub async fn update_param(&mut self, context: &Context) -> Result<()> { @@ -1023,8 +1035,11 @@ impl Chat { return contact.get_profile_image(context).await; } } + } else if self.typ == Chattype::Broadcast { + if let Ok(image_rel) = get_broadcast_icon(context).await { + return Ok(Some(dc_get_abs_path(context, image_rel))); + } } - Ok(None) } @@ -1126,18 +1141,18 @@ impl Chat { let mut to_id = 0; let mut location_id = 0; - if !(self.typ == Chattype::Single || self.typ == Chattype::Group) { - error!(context, "Cannot send to chat type #{}.", self.typ,); - bail!("Cannot set to chat type #{}", self.typ); - } - - if self.typ == Chattype::Group - && !is_contact_in_chat(context, self.id, DC_CONTACT_ID_SELF).await? - { - context.emit_event(EventType::ErrorSelfNotInGroup( - "Cannot send message; self not in group.".into(), - )); - bail!("Cannot set message; self not in group."); + if !self.can_send(context).await? { + if self.typ == Chattype::Group + && !is_contact_in_chat(context, self.id, DC_CONTACT_ID_SELF).await? + { + context.emit_event(EventType::ErrorSelfNotInGroup( + "Cannot send message; self not in group.".into(), + )); + bail!("Cannot set message; self not in group."); + } else { + error!(context, "Cannot send to chat type #{}.", self.typ,); + bail!("Cannot send to chat type #{}", self.typ); + } } let from = context @@ -1461,6 +1476,21 @@ pub(crate) async fn update_device_icon(context: &Context) -> Result<()> { Ok(()) } +pub(crate) async fn get_broadcast_icon(context: &Context) -> Result { + if let Some(icon) = context.sql.get_raw_config("icon-broadcast").await? { + return Ok(icon); + } + + let icon = include_bytes!("../assets/icon-broadcast.png"); + let blob = BlobObject::create(context, "icon-broadcast.png", icon).await?; + let icon = blob.as_name().to_string(); + context + .sql + .set_raw_config("icon-broadcast", Some(&icon)) + .await?; + Ok(icon) +} + async fn update_special_chat_name(context: &Context, contact_id: u32, name: String) -> Result<()> { if let Some(chat_id) = ChatId::lookup_by_contact(context, contact_id).await? { // the `!= name` condition avoids unneeded writes @@ -2221,6 +2251,56 @@ pub async fn create_group_chat( Ok(chat_id) } +/// Finds an unused name for a new broadcast list. +async fn find_unused_broadcast_list_name(context: &Context) -> Result { + let base_name = stock_str::broadcast_list(context).await; + for attempt in 1..1000 { + let better_name = if attempt > 1 { + format!("{} {}", base_name, attempt) + } else { + base_name.clone() + }; + if !context + .sql + .exists( + "SELECT COUNT(*) FROM chats WHERE type=? AND name=?;", + paramsv![Chattype::Broadcast, better_name], + ) + .await? + { + return Ok(better_name); + } + } + Ok(base_name) +} + +/// Creates a new broadcast list. +pub async fn create_broadcast_list(context: &Context) -> Result { + let chat_name = find_unused_broadcast_list_name(context).await?; + let grpid = dc_create_id(); + let row_id = context + .sql + .insert( + "INSERT INTO chats + (type, name, grpid, param, created_timestamp) + VALUES(?, ?, ?, \'U=1\', ?);", + paramsv![ + Chattype::Broadcast, + chat_name, + grpid, + dc_create_smeared_timestamp(context).await, + ], + ) + .await?; + let chat_id = ChatId::new(u32::try_from(row_id)?); + + context.emit_event(EventType::MsgsChanged { + msg_id: MsgId::new(0), + chat_id: ChatId::new(0), + }); + Ok(chat_id) +} + /// Adds a contact to the `chats_contacts` table. pub(crate) async fn add_to_chat_contacts_table( context: &Context, @@ -2278,8 +2358,8 @@ pub(crate) async fn add_contact_to_chat_ex( /*this also makes sure, not contacts are added to special or normal chats*/ let mut chat = Chat::load_from_db(context, chat_id).await?; ensure!( - chat.typ == Chattype::Group, - "{} is not a group where one can add members", + chat.typ == Chattype::Group || chat.typ == Chattype::Broadcast, + "{} is not a group/broadcast where one can add members", chat_id ); ensure!( @@ -2288,14 +2368,18 @@ pub(crate) async fn add_contact_to_chat_ex( contact_id ); ensure!(!chat.is_mailing_list(), "Mailing lists can't be changed"); + ensure!( + chat.typ != Chattype::Broadcast || contact_id != DC_CONTACT_ID_SELF, + "Cannot add SELF to broadcast." + ); - if !is_contact_in_chat(context, chat_id, DC_CONTACT_ID_SELF).await? { - /* we should respect this - whatever we send to the group, it gets discarded anyway! */ + if !chat.is_self_in_chat(context).await? { context.emit_event(EventType::ErrorSelfNotInGroup( "Cannot add contact to group; self not in group.".into(), )); bail!("can not add contact because our account is not part of it"); } + if from_handshake && chat.param.get_int(Param::Unpromoted).unwrap_or_default() == 1 { chat.param.remove(Param::Unpromoted); chat.update_param(context).await?; @@ -2334,7 +2418,7 @@ pub(crate) async fn add_contact_to_chat_ex( } add_to_chat_contacts_table(context, chat_id, contact_id).await?; } - if chat.param.get_int(Param::Unpromoted).unwrap_or_default() == 0 { + if chat.typ == Chattype::Group && chat.is_promoted() { msg.viewtype = Viewtype::Text; msg.text = Some( @@ -2499,14 +2583,14 @@ pub async fn remove_contact_from_chat( /* we do not check if "contact_id" exists but just delete all records with the id from chats_contacts */ /* this allows to delete pending references to deleted contacts. Of course, this should _not_ happen. */ if let Ok(chat) = Chat::load_from_db(context, chat_id).await { - if chat.typ == Chattype::Group { - if !is_contact_in_chat(context, chat_id, DC_CONTACT_ID_SELF).await? { + if chat.typ == Chattype::Group || chat.typ == Chattype::Broadcast { + if !chat.is_self_in_chat(context).await? { context.emit_event(EventType::ErrorSelfNotInGroup( "Cannot remove contact from chat; self not in group.".into(), )); } else { if let Ok(contact) = Contact::get_by_id(context, contact_id).await { - if chat.is_promoted() { + if chat.typ == Chattype::Group && chat.is_promoted() { msg.viewtype = Viewtype::Text; if contact.id == DC_CONTACT_ID_SELF { set_group_explicitly_left(context, &chat.grpid).await?; @@ -2593,48 +2677,47 @@ pub async fn set_chat_name(context: &Context, chat_id: ChatId, new_name: &str) - let chat = Chat::load_from_db(context, chat_id).await?; let mut msg = Message::default(); - if chat.typ == Chattype::Group || chat.typ == Chattype::Mailinglist { + if chat.typ == Chattype::Group + || chat.typ == Chattype::Mailinglist + || chat.typ == Chattype::Broadcast + { if chat.name == new_name { success = true; - } else if !is_contact_in_chat(context, chat_id, DC_CONTACT_ID_SELF).await? { + } else if !chat.is_self_in_chat(context).await? { context.emit_event(EventType::ErrorSelfNotInGroup( "Cannot set chat name; self not in group".into(), )); } else { - /* we should respect this - whatever we send to the group, it gets discarded anyway! */ - if context + context .sql .execute( "UPDATE chats SET name=? WHERE id=?;", paramsv![new_name.to_string(), chat_id], ) - .await - .is_ok() - { - if chat.is_promoted() && !chat.is_mailing_list() { - msg.viewtype = Viewtype::Text; - msg.text = Some( - stock_str::msg_grp_name( - context, - &chat.name, - &new_name, - DC_CONTACT_ID_SELF as u32, - ) - .await, - ); - msg.param.set_cmd(SystemMessage::GroupNameChanged); - if !chat.name.is_empty() { - msg.param.set(Param::Arg, &chat.name); - } - msg.id = send_msg(context, chat_id, &mut msg).await?; - context.emit_event(EventType::MsgsChanged { - chat_id, - msg_id: msg.id, - }); + .await?; + if chat.is_promoted() && !chat.is_mailing_list() && chat.typ != Chattype::Broadcast { + msg.viewtype = Viewtype::Text; + msg.text = Some( + stock_str::msg_grp_name( + context, + &chat.name, + &new_name, + DC_CONTACT_ID_SELF as u32, + ) + .await, + ); + msg.param.set_cmd(SystemMessage::GroupNameChanged); + if !chat.name.is_empty() { + msg.param.set(Param::Arg, &chat.name); } - context.emit_event(EventType::ChatModified(chat_id)); - success = true; + msg.id = send_msg(context, chat_id, &mut msg).await?; + context.emit_event(EventType::MsgsChanged { + chat_id, + msg_id: msg.id, + }); } + context.emit_event(EventType::ChatModified(chat_id)); + success = true; } } @@ -4243,4 +4326,49 @@ mod tests { ); Ok(()) } + + #[async_std::test] + async fn test_broadcast() -> Result<()> { + // create two context, send two messages so both know the other + let alice = TestContext::new_alice().await; + let bob = TestContext::new_bob().await; + + let chat_alice = alice.create_chat(&bob).await; + send_text_msg(&alice, chat_alice.id, "hi!".to_string()).await?; + bob.recv_msg(&alice.pop_sent_msg().await).await; + + let chat_bob = bob.create_chat(&alice).await; + send_text_msg(&bob, chat_bob.id, "ho!".to_string()).await?; + alice.recv_msg(&bob.pop_sent_msg().await).await; + let msg = alice.get_last_msg().await; + assert!(msg.get_showpadlock()); + + // test broadcast list + let broadcast_id = create_broadcast_list(&alice).await?; + add_contact_to_chat( + &alice, + broadcast_id, + get_chat_contacts(&alice, chat_bob.id).await?.pop().unwrap(), + ) + .await?; + let chat = Chat::load_from_db(&alice, broadcast_id).await?; + assert_eq!(chat.typ, Chattype::Broadcast); + assert_eq!(chat.name, stock_str::broadcast_list(&alice).await); + assert!(!chat.is_self_talk()); + + send_text_msg(&alice, broadcast_id, "ola!".to_string()).await?; + let msg = alice.get_last_msg().await; + assert_eq!(msg.chat_id, chat.id); + + bob.recv_msg(&alice.pop_sent_msg().await).await; + let msg = bob.get_last_msg().await; + assert_eq!(msg.get_text(), Some("ola!".to_string())); + assert!(!msg.get_showpadlock()); // avoid leaking recipients in encryption data + let chat = Chat::load_from_db(&bob, msg.chat_id).await?; + assert_eq!(chat.typ, Chattype::Single); + assert_eq!(chat.id, chat_bob.id); + assert!(!chat.is_self_talk()); + + Ok(()) + } } diff --git a/src/chatlist.rs b/src/chatlist.rs index c33ff8df7..a8cd888e7 100644 --- a/src/chatlist.rs +++ b/src/chatlist.rs @@ -329,7 +329,7 @@ impl Chatlist { (Some(lastmsg), None) } else { match chat.typ { - Chattype::Group | Chattype::Mailinglist => { + Chattype::Group | Chattype::Broadcast | Chattype::Mailinglist => { let lastcontact = Contact::load_from_db(context, lastmsg.from_id).await?; (Some(lastmsg), Some(lastcontact)) } diff --git a/src/constants.rs b/src/constants.rs index 5f1e990ba..108351cc0 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -153,6 +153,7 @@ pub enum Chattype { Single = 100, Group = 120, Mailinglist = 140, + Broadcast = 160, } impl Default for Chattype { @@ -348,6 +349,7 @@ mod tests { assert_eq!(Chattype::Single, Chattype::from_i32(100).unwrap()); assert_eq!(Chattype::Group, Chattype::from_i32(120).unwrap()); assert_eq!(Chattype::Mailinglist, Chattype::from_i32(140).unwrap()); + assert_eq!(Chattype::Broadcast, Chattype::from_i32(160).unwrap()); } #[test] diff --git a/src/message.rs b/src/message.rs index 3674cb969..c7ebeca5e 100644 --- a/src/message.rs +++ b/src/message.rs @@ -604,7 +604,7 @@ impl Message { let contact = if self.from_id != DC_CONTACT_ID_SELF { match chat.typ { - Chattype::Group | Chattype::Mailinglist => { + Chattype::Group | Chattype::Broadcast | Chattype::Mailinglist => { Some(Contact::get_by_id(context, self.from_id).await?) } Chattype::Single | Chattype::Undefined => None, @@ -1599,7 +1599,7 @@ async fn ndn_maybe_add_info_msg( chat_type: Chattype, ) -> Result<()> { match chat_type { - Chattype::Group => { + Chattype::Group | Chattype::Broadcast => { if let Some(failed_recipient) = &failed.failed_recipient { let contact_id = Contact::lookup_id_by_addr(context, failed_recipient, Origin::Unknown) diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 79c1fb926..210de7eb0 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -316,6 +316,10 @@ impl<'a> MimeFactory<'a> { Loaded::Message { chat } => { if chat.is_protected() { false + } else if chat.typ == Chattype::Broadcast { + // encryption may disclose recipients; + // this is probably a worse issue than not opportunistically (!) encrypting + true } else { self.msg .param @@ -393,8 +397,6 @@ impl<'a> MimeFactory<'a> { } if chat.typ == Chattype::Group && quoted_msg_subject.is_none_or_empty() { - // If we have a `quoted_msg_subject`, we use the subject of the quoted message - // instead of the group name let re = if self.in_reply_to.is_empty() { "" } else { @@ -403,22 +405,22 @@ impl<'a> MimeFactory<'a> { return Ok(format!("{}{}", re, chat.name)); } - let parent_subject = if quoted_msg_subject.is_none_or_empty() { - chat.param.get(Param::LastSubject) - } else { - quoted_msg_subject.as_deref() - }; - - if let Some(last_subject) = parent_subject { - format!("Re: {}", remove_subject_prefix(last_subject)) - } else { - let self_name = match context.get_config(Config::Displayname).await? { - Some(name) => name, - None => context.get_config(Config::Addr).await?.unwrap_or_default(), + if chat.typ != Chattype::Broadcast { + let parent_subject = if quoted_msg_subject.is_none_or_empty() { + chat.param.get(Param::LastSubject) + } else { + quoted_msg_subject.as_deref() }; - - stock_str::subject_for_new_contact(context, self_name).await + if let Some(last_subject) = parent_subject { + return Ok(format!("Re: {}", remove_subject_prefix(last_subject))); + } } + + let self_name = match context.get_config(Config::Displayname).await? { + Some(name) => name, + None => context.get_config(Config::Addr).await?.unwrap_or_default(), + }; + stock_str::subject_for_new_contact(context, self_name).await } Loaded::Mdn { .. } => stock_str::read_rcpt(context).await, }; @@ -555,9 +557,21 @@ impl<'a> MimeFactory<'a> { render_rfc724_mid(&rfc724_mid), )); - headers - .unprotected - .push(Header::new_with_value("To".into(), to).unwrap()); + let undisclosed_recipients = match &self.loaded { + Loaded::Message { chat } => chat.typ == Chattype::Broadcast, + Loaded::Mdn { .. } => false, + }; + + if undisclosed_recipients { + headers + .unprotected + .push(Header::new("To".into(), "hidden-recipients: ;".to_string())); + } else { + headers + .unprotected + .push(Header::new_with_value("To".into(), to).unwrap()); + } + headers .unprotected .push(Header::new_with_value("From".into(), vec![from]).unwrap()); diff --git a/src/stock_str.rs b/src/stock_str.rs index 27fe437e3..e9fd1e621 100644 --- a/src/stock_str.rs +++ b/src/stock_str.rs @@ -311,6 +311,9 @@ pub enum StockMessage { #[strum(props(fallback = "Messages"))] Messages = 114, + + #[strum(props(fallback = "Broadcast List"))] + BroadcastList = 115, } impl StockMessage { @@ -985,6 +988,12 @@ pub(crate) async fn messages(context: &Context) -> String { translated(context, StockMessage::Messages).await } +/// Stock string: `Broadcast List`. +/// Used as the default name for broadcast lists; a number may be added. +pub(crate) async fn broadcast_list(context: &Context) -> String { + translated(context, StockMessage::BroadcastList).await +} + impl Context { /// Set the stock string for the [StockMessage]. /// diff --git a/src/summary.rs b/src/summary.rs index e6fe559d3..885ff2a85 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -72,7 +72,7 @@ impl Summary { } } else { match chat.typ { - Chattype::Group | Chattype::Mailinglist => { + Chattype::Group | Chattype::Broadcast | Chattype::Mailinglist => { if msg.is_info() || contact.is_none() { None } else {