From 3c6fcc92560124993cb2e4d99a8e2ac107b7e487 Mon Sep 17 00:00:00 2001 From: link2xt Date: Thu, 18 Jun 2026 01:04:01 +0200 Subject: [PATCH 1/2] refactor(mimefactory): separate rendering of message payload and sendable message This change separates rendering into two separate steps: 1. Rendering of the message payload without the From, Date and Autocrypt headers. 2. Adding the From, Date and Autocrypt headers and possibly encrypting the message. The goal is to have serializable result of the first step that can be persisted in the database and sent later with any email address. This way it will be possible to send queued messages over any relay. This will make it possible not to remove all messages from the queue when the sending relay is changed. Currently changing `configured_addr` deletes everything from `smtp` table. This change is however only a refactoring and does not implement any features. --- src/chat.rs | 59 ++- src/download.rs | 2 +- src/e2ee.rs | 100 +---- src/imap.rs | 3 +- src/mimefactory.rs | 984 +++++++++++++++++++++++++++------------------ src/pgp.rs | 159 ++++---- src/reaction.rs | 3 +- src/test_utils.rs | 32 +- 8 files changed, 747 insertions(+), 595 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index 7c2a855a6b..0ad2fd49d4 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -35,11 +35,13 @@ use crate::download::{ use crate::ensure_and_debug_assert_eq; use crate::ephemeral::{Timer as EphemeralTimer, start_chat_ephemeral_timers}; use crate::events::EventType; +use crate::key; use crate::key::{Fingerprint, self_fingerprint}; use crate::location; use crate::log::{LogExt, warn}; use crate::logged_debug_assert; use crate::message::{self, Message, MessageState, MsgId, Viewtype}; +use crate::mimefactory; use crate::mimefactory::{MimeFactory, RenderedEmail}; use crate::mimeparser::SystemMessage; use crate::param::{Param, Params}; @@ -2757,6 +2759,11 @@ async fn render_mime_message_and_pre_message( msg: &mut Message, mimefactory: MimeFactory, ) -> Result<(Option, RenderedEmail)> { + let from_addr = context.get_primary_self_addr().await?; + let public_key = key::load_self_public_key(context).await?; + let secret_key = key::load_self_secret_key(context).await?; + let timestamp = msg.timestamp_sort; + let needs_pre_message = msg.viewtype.has_file() && mimefactory.will_be_encrypted() // unencrypted is likely email, we don't want to spam by sending multiple messages && msg @@ -2773,15 +2780,32 @@ async fn render_mime_message_and_pre_message( let mut mimefactory_post_msg = mimefactory.clone(); mimefactory_post_msg.set_as_post_message(); - let rendered_msg = Box::pin(mimefactory_post_msg.render(context)) + let (queued_msg, side_effects) = Box::pin(mimefactory_post_msg.pre_render(context)) .await .context("Failed to render post-message")?; + let rendered_msg = mimefactory::render_queued_mail( + queued_msg, + &public_key, + &secret_key, + from_addr.clone(), + timestamp, + side_effects, + )?; + let mut mimefactory_pre_msg = mimefactory; mimefactory_pre_msg.set_as_pre_message_for(&rendered_msg); - let rendered_pre_msg = Box::pin(mimefactory_pre_msg.render(context)) + let (queued_pre_msg, pre_side_effects) = Box::pin(mimefactory_pre_msg.pre_render(context)) .await .context("pre-message failed to render")?; + let rendered_pre_msg = mimefactory::render_queued_mail( + queued_pre_msg, + &public_key, + &secret_key, + from_addr, + timestamp, + pre_side_effects, + )?; if rendered_pre_msg.message.len() > PRE_MSG_SIZE_WARNING_THRESHOLD { warn!( @@ -2794,7 +2818,17 @@ async fn render_mime_message_and_pre_message( Ok((Some(rendered_pre_msg), rendered_msg)) } else { - Ok((None, Box::pin(mimefactory.render(context)).await?)) + let (queued_msg, side_effects) = Box::pin(mimefactory.pre_render(context)).await?; + let rendered_msg = mimefactory::render_queued_mail( + queued_msg, + &public_key, + &secret_key, + from_addr, + timestamp, + side_effects, + )?; + + Ok((None, rendered_msg)) } } @@ -2839,7 +2873,6 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) - return Err(err); } }; - let attach_selfavatar = mimefactory.attach_selfavatar; let mut recipients = mimefactory.recipients(); let from = context.get_primary_self_addr().await?; @@ -2926,14 +2959,22 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) - let now = time(); - if let Some(last_added_location_timestamp) = rendered_msg.last_added_location_timestamp { + if let Some(last_added_location_timestamp) = + rendered_msg.side_effects.last_added_location_timestamp + { location::set_kml_sent_timestamp(context, msg.chat_id, last_added_location_timestamp) .await?; } - if attach_selfavatar && let Err(err) = msg.chat_id.set_selfavatar_timestamp(context, now).await + if rendered_msg.side_effects.avatar_is_attached + || rendered_pre_msg + .as_ref() + .is_some_and(|msg| msg.side_effects.avatar_is_attached) { - error!(context, "Failed to set selfavatar timestamp: {err:#}."); + msg.chat_id + .set_selfavatar_timestamp(context, now) + .await + .context("Failed to set selfavatar timestamp")?; } if rendered_msg.is_encrypted { @@ -2941,7 +2982,7 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) - } else { msg.param.remove(Param::GuaranteeE2ee); } - msg.subject.clone_from(&rendered_msg.subject); + msg.subject.clone_from(&rendered_msg.side_effects.subject); // Sort the message to the bottom. Employ `msgs_index7` to compute `timestamp`. context .sql @@ -2974,7 +3015,7 @@ WHERE id=? let trans_fn = |t: &mut rusqlite::Transaction| { let mut row_ids = Vec::::new(); - if let Some(sync_ids) = rendered_msg.sync_ids_to_delete { + if let Some(sync_ids) = rendered_msg.side_effects.sync_ids_to_delete { t.execute( &format!("DELETE FROM multi_device_sync WHERE id IN ({sync_ids})"), (), diff --git a/src/download.rs b/src/download.rs index b5f9ec0c1d..1a8b0cf4b9 100644 --- a/src/download.rs +++ b/src/download.rs @@ -209,7 +209,7 @@ impl Session { let (sender, receiver) = async_channel::unbounded(); { let _fetch_msgs_lock_guard = context.fetch_msgs_mutex.lock().await; - self.fetch_many_msgs(context, folder, vec![uid], &uid_message_ids, sender) + Box::pin(self.fetch_many_msgs(context, folder, vec![uid], &uid_message_ids, sender)) .await?; } if receiver.recv().await.is_err() { diff --git a/src/e2ee.rs b/src/e2ee.rs index 77606419c2..8e25272ff6 100644 --- a/src/e2ee.rs +++ b/src/e2ee.rs @@ -1,105 +1,9 @@ //! End-to-end encryption support. -use std::io::Cursor; - -use anyhow::Result; -use mail_builder::mime::MimePart; - -use crate::aheader::{Aheader, EncryptPreference}; -use crate::context::Context; -use crate::key::{SignedPublicKey, load_self_public_key, load_self_secret_key}; -use crate::pgp::{self, SeipdVersion}; - -#[derive(Debug)] -pub struct EncryptHelper { - pub addr: String, - pub public_key: SignedPublicKey, -} - -impl EncryptHelper { - pub async fn new(context: &Context) -> Result { - let addr = context.get_primary_self_addr().await?; - let public_key = load_self_public_key(context).await?; - - Ok(EncryptHelper { addr, public_key }) - } - - pub fn get_aheader(&self) -> Aheader { - Aheader { - addr: self.addr.clone(), - public_key: self.public_key.clone(), - prefer_encrypt: EncryptPreference::Mutual, - verified: false, - } - } - - /// Tries to encrypt the passed in `mail`. - pub async fn encrypt( - self, - context: &Context, - keyring: Vec, - mail_to_encrypt: MimePart<'static>, - compress: bool, - seipd_version: SeipdVersion, - ) -> Result { - let mut raw_message = Vec::new(); - let cursor = Cursor::new(&mut raw_message); - mail_to_encrypt.clone().write_part(cursor).ok(); - - let ctext = self - .encrypt_raw(context, keyring, raw_message, compress, seipd_version) - .await?; - Ok(ctext) - } - - pub async fn encrypt_raw( - self, - context: &Context, - keyring: Vec, - raw_message: Vec, - compress: bool, - seipd_version: SeipdVersion, - ) -> Result { - let sign_key = load_self_secret_key(context).await?; - let ctext = - pgp::pk_encrypt(raw_message, keyring, sign_key, compress, seipd_version).await?; - - Ok(ctext) - } - - /// Symmetrically encrypt the message. This is used for broadcast channels. - /// `shared secret` is the secret that will be used for symmetric encryption. - pub async fn encrypt_symmetrically( - self, - context: &Context, - shared_secret: &str, - mail_to_encrypt: MimePart<'static>, - compress: bool, - sign: bool, - ) -> Result { - let sign_key = if sign { - Some(load_self_secret_key(context).await?) - } else { - None - }; - - let shared_secret = shared_secret.to_string(); - let mut raw_message = Vec::new(); - let cursor = Cursor::new(&mut raw_message); - mail_to_encrypt.clone().write_part(cursor).ok(); - - let ctext = tokio::task::spawn_blocking(move || { - pgp::symm_encrypt_message(raw_message, sign_key, shared_secret, compress) - }) - .await??; - - Ok(ctext) - } -} - #[cfg(test)] mod tests { - use super::*; + use anyhow::Result; + use crate::chat; use crate::chat::send_text_msg; use crate::config::Config; diff --git a/src/imap.rs b/src/imap.rs index a34e8590e4..c7b01558ed 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -753,8 +753,7 @@ impl Imap { }; let actually_download_messages_future = async { - session - .fetch_many_msgs(context, folder, uids_fetch, &uid_message_ids, sender) + Box::pin(session.fetch_many_msgs(context, folder, uids_fetch, &uid_message_ids, sender)) .await .context("fetch_many_msgs") }; diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 3ee7f35920..fa4bebe02e 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -8,6 +8,7 @@ use base64::Engine as _; use data_encoding::BASE32_NOPAD; use deltachat_contact_tools::sanitize_bidi_characters; use iroh_gossip::proto::TopicId; +use mail_builder::headers::Header as _; use mail_builder::headers::HeaderType; use mail_builder::headers::address::Address; use mail_builder::mime::MimePart; @@ -21,11 +22,11 @@ use crate::constants::{BROADCAST_INCOMPATIBILITY_MSG, Chattype, DC_FROM_HANDSHAK use crate::contact::{Contact, ContactId, Origin}; use crate::context::Context; use crate::download::PostMsgMetadata; -use crate::e2ee::EncryptHelper; use crate::ensure_and_debug_assert; use crate::ephemeral::Timer as EphemeralTimer; use crate::headerdef::HeaderDef; -use crate::key::{DcKey, SignedPublicKey, self_fingerprint}; +use crate::key; +use crate::key::{DcKey, SignedPublicKey, SignedSecretKey, self_fingerprint}; use crate::location; use crate::log::warn; use crate::message::{Message, MsgId, Viewtype}; @@ -142,43 +143,340 @@ pub struct MimeFactory { /// using `Chat-Disposition-Notification-To` header. req_mdn: bool, + /// True if the avatar should be attached. + attach_selfavatar: bool, + + /// This field is used to sustain the topic id of webxdcs needed for peer channels. + webxdc_topic: Option, + + /// Pre-message / post-message / atomic message. + pre_message_mode: PreMessageMode, +} + +/// Result of rendering non-MDN message. +pub struct RenderedMessage { + main_part: MimePart<'static>, + + parts: Vec>, + /// Largest timestamp of the location sent in `location.kml` in this message. last_added_location_timestamp: Option, + /// True if the avatar is attached to the message. + avatar_is_attached: bool, + /// If the created mime-structure contains sync-items, /// the IDs of these items are listed here. /// The IDs are returned via `RenderedEmail` /// and must be deleted if the message is actually queued for sending. sync_ids_to_delete: Option, +} - /// True if the avatar should be attached. - pub attach_selfavatar: bool, +/// Email message queued, but not sent yet. +/// +/// It is stored unencrypted to +/// make it possible to change protected headers +/// like the From address, Autocrypt header +/// and the Date later. +#[derive(Debug, Clone)] +pub(crate) struct QueuedMail { + /// Unencrypted queued message. + /// + /// This message has both the headers and the body, + /// but without the From, Autocrypt and Date headers. + /// + /// For encrypted messages this is the OpenPGP payload. + raw_message: Vec, - /// This field is used to sustain the topic id of webxdcs needed for peer channels. - webxdc_topic: Option, + /// Display name to put in the `From:` field. + /// + /// Email address is not determined yet here. + display_name: String, - /// Pre-message / post-message / atomic message. - pre_message_mode: PreMessageMode, -} + /// Message-ID. + rfc724_mid: String, -/// Result of rendering a message, ready to be submitted to a send job. -#[derive(Debug, Clone)] -pub struct RenderedEmail { - pub message: String, - pub is_encrypted: bool, + /// Public keys to which the message should be encrypted. + encryption_pubkeys: Option>, + + /// Shared secret if the message should be encrypted symmetrically. + shared_secret: Option, + + /// If true, Autocrypt header should be added before sending. + should_attach_pubkey: bool, + /// If true, OpenPGP compression may be used. + should_compress: bool, + + /// If true, encrypted message should be signed as well. + should_sign: bool, +} + +/// Side effects that should be applied at the same time +/// as the message is persisted in the queue. +#[derive(Debug, Clone, Default)] +pub struct RenderSideEffects { /// Largest timestamp of the location sent in `location.kml` in this message. pub last_added_location_timestamp: Option, + /// True if the message has the avatar attached. + /// + /// Timestamp of the last time avatar was gossiped should be updated. + pub avatar_is_attached: bool, + /// A comma-separated string of sync-IDs that are used by the rendered email and must be deleted /// from `multi_device_sync` once the message is actually queued for sending. pub sync_ids_to_delete: Option, + /// Subject that was rendered into the message. + /// + /// Used to update the subject on the sent message object. + pub subject: String, +} + +/// Renders [`QueuedMail`]. +/// +/// Adds headers: +/// - `From` +/// - `Date` +/// - `Autocrypt` +/// - `Message-ID` +/// +/// Encrypts and signs the message if necessary. +pub(crate) fn render_queued_mail( + queued_mail: QueuedMail, + public_key: &SignedPublicKey, + secret_key: &SignedSecretKey, + from_addr: String, + timestamp: i64, + side_effects: RenderSideEffects, +) -> Result { + let QueuedMail { + rfc724_mid, + display_name, + mut raw_message, + encryption_pubkeys, + shared_secret, + should_attach_pubkey, + should_compress, + should_sign, + } = queued_mail; + + let mut inner_headers: Vec = Vec::new(); + let mut outer_headers: Vec = Vec::new(); + + let is_encrypted = encryption_pubkeys.is_some(); + + let from_header = new_address_with_name(&display_name, from_addr.clone()); + inner_headers.extend_from_slice(b"From: "); + from_header.write_header(&mut inner_headers, 6)?; + + if is_encrypted { + let unencrypted_from = Address::new_address(None::<&'static str>, from_addr.to_string()); + outer_headers.extend_from_slice(b"From: "); + unencrypted_from.write_header(&mut outer_headers, 6)?; + + inner_headers.extend_from_slice(b"HP-Outer: From: "); + unencrypted_from.write_header(&mut inner_headers, 16)?; + } else { + outer_headers.extend_from_slice(b"From: "); + from_header.write_header(&mut outer_headers, 6)?; + } + + let date = chrono::DateTime::::from_timestamp(timestamp, 0) + .unwrap() + .to_rfc2822(); + inner_headers.extend_from_slice(b"Date: "); + inner_headers.extend_from_slice(date.as_bytes()); + inner_headers.extend_from_slice(b"\r\n"); + + if is_encrypted { + inner_headers.extend_from_slice(b"HP-Outer: Date: "); + inner_headers.extend_from_slice(date.as_bytes()); + inner_headers.extend_from_slice(b"\r\n"); + + // Randomized date goes to unprotected header. + // + // We cannot just send "Thu, 01 Jan 1970 00:00:00 +0000" + // or omit the header because GMX then fails with + // + // host mx00.emig.gmx.net[212.227.15.9] said: + // 554-Transaction failed + // 554-Reject due to policy restrictions. + // 554 For explanation visit https://postmaster.gmx.net/en/case?... + // (in reply to end of DATA command) + // + // and the explanation page says + // "The time information deviates too much from the actual time". + // + // We also limit the range to 6 days (518400 seconds) + // because with a larger range we got + // error "500 Date header far in the past/future" + // which apparently originates from Symantec Messaging Gateway + // and means the message has a Date that is more + // than 7 days in the past: + // + let timestamp_offset = rand::random_range(0..518400); + let protected_timestamp = timestamp.saturating_sub(timestamp_offset); + let unprotected_date = + chrono::DateTime::::from_timestamp(protected_timestamp, 0) + .unwrap() + .to_rfc2822(); + outer_headers.extend_from_slice(b"Date: "); + outer_headers.extend_from_slice(unprotected_date.as_bytes()); + outer_headers.extend_from_slice(b"\r\n"); + } else { + outer_headers.extend_from_slice(b"Date: "); + outer_headers.extend_from_slice(date.as_bytes()); + outer_headers.extend_from_slice(b"\r\n"); + } + + inner_headers.extend_from_slice(b"Message-ID: <"); + inner_headers.extend_from_slice(rfc724_mid.as_bytes()); + inner_headers.extend_from_slice(b">\r\n"); + outer_headers.extend_from_slice(b"Message-ID: <"); + outer_headers.extend_from_slice(rfc724_mid.as_bytes()); + outer_headers.extend_from_slice(b">\r\n"); + if is_encrypted { + inner_headers.extend_from_slice(b"HP-Outer: Message-ID: <"); + inner_headers.extend_from_slice(rfc724_mid.as_bytes()); + inner_headers.extend_from_slice(b">\r\n"); + } + + // MIME header . + outer_headers.extend_from_slice(b"MIME-Version: 1.0\r\n"); + + if should_attach_pubkey { + let aheader = Aheader { + addr: from_addr.clone(), + public_key: public_key.clone(), + prefer_encrypt: EncryptPreference::Mutual, + verified: false, + }; + let autocrypt_header = mail_builder::headers::raw::Raw::new(aheader.to_string()); + if is_encrypted { + inner_headers.extend_from_slice(b"Autocrypt: "); + autocrypt_header.write_header(&mut inner_headers, 11)?; + } else { + outer_headers.extend_from_slice(b"Autocrypt: "); + autocrypt_header.write_header(&mut outer_headers, 11)?; + } + } + + if is_encrypted { + // Copy not protected headers to outer headers. + let (parsed_headers, _index) = mailparse::parse_headers(&raw_message)?; + for parsed_header in parsed_headers { + let original_header_name = parsed_header.get_key(); + let header_name = original_header_name.to_lowercase(); + + if header_name == "mime-version" + || header_name == "content-type" + || header_name == "content-transfer-encoding" + || header_name == "content-disposition" + { + // Structural headers shouldn't be added as "HP-Outer". They are defined in + // . + continue; + } + let header_value = if header_name == "mime-version" + || header_name == "chat-version" + || header_name == "chat-is-post-message" + { + parsed_header.get_value_raw() + } else if header_name == "subject" { + &b"[...]"[..] + } else if header_name == "to" { + &b"To: \"hidden-recipients\""[..] + } else { + continue; + }; + + outer_headers.extend_from_slice(original_header_name.as_bytes()); + outer_headers.extend_from_slice(b": "); + outer_headers.extend_from_slice(header_value); + outer_headers.extend_from_slice(b"\r\n"); + + inner_headers.extend_from_slice(b"HP-Outer: "); + inner_headers.extend_from_slice(original_header_name.as_bytes()); + inner_headers.extend_from_slice(b": "); + inner_headers.extend_from_slice(header_value); + inner_headers.extend_from_slice(b"\r\n"); + } + } + + let mut message = if let Some(encryption_pubkeys) = encryption_pubkeys { + let mut full_raw_message = inner_headers.clone(); + full_raw_message.append(&mut raw_message); + + let encrypted = if let Some(shared_secret) = shared_secret { + let sign_key = if should_sign { + Some(secret_key.clone()) + } else { + None + }; + + crate::pgp::symm_encrypt_message( + full_raw_message, + sign_key, + shared_secret, + should_compress, + )? + } else { + // Asymmetric encryption + + // Use SEIPDv2 if all recipients support it. + let seipd_version = if encryption_pubkeys + .iter() + .all(|(_addr, pubkey)| pubkey_supports_seipdv2(pubkey)) + { + SeipdVersion::V2 + } else { + SeipdVersion::V1 + }; + + // Encrypt to self unconditionally, + // even for a single-device setup. + let mut encryption_keyring = vec![public_key.clone()]; + encryption_keyring.extend(encryption_pubkeys.iter().map(|(_addr, key)| (*key).clone())); + + crate::pgp::pk_encrypt( + full_raw_message, + encryption_keyring, + secret_key.clone(), + should_compress, + seipd_version, + )? + }; + + let message = wrap_encrypted_part(encrypted); + + part_to_vec(message) + } else { + raw_message + }; + + let mut full_message = outer_headers; + full_message.append(&mut message); + Ok(RenderedEmail { + message: String::from_utf8_lossy(&full_message).to_string(), + is_encrypted, + rfc724_mid, + side_effects, + }) +} + +/// Result of rendering a message, ready to be submitted to a send job. +#[derive(Debug, Clone)] +pub struct RenderedEmail { + pub message: String, + + pub is_encrypted: bool, + /// Message ID (Message in the sense of Email) pub rfc724_mid: String, - /// Message subject. - pub subject: String, + pub side_effects: RenderSideEffects, } fn new_address_with_name(name: &str, address: String) -> Address<'static> { @@ -558,8 +856,6 @@ impl MimeFactory { in_reply_to, references, req_mdn, - last_added_location_timestamp: None, - sync_ids_to_delete: None, attach_selfavatar, webxdc_topic, pre_message_mode: PreMessageMode::None, @@ -609,8 +905,6 @@ impl MimeFactory { in_reply_to: String::default(), references: Vec::new(), req_mdn: false, - last_added_location_timestamp: None, - sync_ids_to_delete: None, attach_selfavatar: false, webxdc_topic: None, pre_message_mode: PreMessageMode::None, @@ -737,53 +1031,40 @@ impl MimeFactory { self.recipients.clone() } - /// Consumes a `MimeFactory` and renders it into a message which is then stored in - /// `smtp`-table to be used by the SMTP loop - #[expect(clippy::arithmetic_side_effects)] - pub async fn render(mut self, context: &Context) -> Result { - let mut headers = Vec::<(&'static str, HeaderType<'static>)>::new(); - - let from = new_address_with_name(&self.from_displayname, self.from_addr.clone()); - - let mut to: Vec> = Vec::new(); - for (name, addr) in &self.to { - to.push(Address::new_address( - if name.is_empty() { - None - } else { - Some(name.to_string()) - }, - addr.clone(), - )); - } - - let mut past_members: Vec> = Vec::new(); // Contents of `Chat-Group-Past-Members` header. - for (name, addr) in &self.past_members { - past_members.push(Address::new_address( - if name.is_empty() { - None - } else { - Some(name.to_string()) - }, - addr.clone(), - )); - } - + async fn render_headers( + &mut self, + context: &Context, + subject_str: &str, + ) -> Result)>> { ensure_and_debug_assert!( self.member_timestamps.is_empty() - || to.len() + past_members.len() == self.member_timestamps.len(), - "to.len() ({}) + past_members.len() ({}) != self.member_timestamps.len() ({})", - to.len(), - past_members.len(), + || self.to.len().checked_add(self.past_members.len()) + == Some(self.member_timestamps.len()), + "self.to.len() ({}) + self.past_members.len() ({}) != self.member_timestamps.len() ({})", + self.to.len(), + self.past_members.len(), self.member_timestamps.len(), ); - if to.is_empty() { - to.push(hidden_recipients()); - } - // Start with Internet Message Format headers in the order of the standard example - // . - headers.push(("From", from.into())); + let mut headers = Vec::<(&'static str, HeaderType<'static>)>::new(); + + let to: Vec> = if self.to.is_empty() { + vec![hidden_recipients()] + } else { + self.to + .iter() + .map(|(name, addr)| { + Address::new_address( + if name.is_empty() { + None + } else { + Some(name.to_string()) + }, + addr.clone(), + ) + }) + .collect() + }; if let Some(sender_displayname) = &self.sender_displayname { let sender = new_address_with_name(sender_displayname, self.from_addr.clone()); @@ -793,10 +1074,25 @@ impl MimeFactory { "To", mail_builder::headers::address::Address::new_list(to.clone()).into(), )); - if !past_members.is_empty() { + + if !self.past_members.is_empty() { + let past_members: Vec> = self + .past_members + .iter() + .map(|(name, addr)| { + Address::new_address( + if name.is_empty() { + None + } else { + Some(name.to_string()) + }, + addr.clone(), + ) + }) + .collect(); headers.push(( "Chat-Group-Past-Members", - mail_builder::headers::address::Address::new_list(past_members.clone()).into(), + mail_builder::headers::address::Address::new_list(past_members).into(), )); } @@ -832,35 +1128,11 @@ impl MimeFactory { } } - let subject_str = self.subject_str(context).await?; headers.push(( "Subject", mail_builder::headers::text::Text::new(subject_str.to_string()).into(), )); - let date = chrono::DateTime::::from_timestamp(self.timestamp, 0) - .unwrap() - .to_rfc2822(); - headers.push(("Date", mail_builder::headers::raw::Raw::new(date).into())); - - let rfc724_mid = match &self.loaded { - Loaded::Message { msg, .. } => match &self.pre_message_mode { - PreMessageMode::Pre { .. } => { - if msg.pre_rfc724_mid.is_empty() { - create_outgoing_rfc724_mid() - } else { - msg.pre_rfc724_mid.clone() - } - } - _ => msg.rfc724_mid.clone(), - }, - Loaded::Mdn { .. } => create_outgoing_rfc724_mid(), - }; - headers.push(( - "Message-ID", - mail_builder::headers::message_id::MessageId::new(rfc724_mid.clone()).into(), - )); - // Reply headers as in . if !self.in_reply_to.is_empty() { headers.push(( @@ -928,7 +1200,6 @@ impl MimeFactory { } } - // Non-standard headers. headers.push(( "Chat-Version", mail_builder::headers::raw::Raw::new("1.0").into(), @@ -944,19 +1215,6 @@ impl MimeFactory { )); } - let grpimage = self.grpimage(); - let skip_autocrypt = self.should_skip_autocrypt(); - let encrypt_helper = EncryptHelper::new(context).await?; - - if !skip_autocrypt { - // unless determined otherwise we add the Autocrypt header - let aheader = encrypt_helper.get_aheader().to_string(); - headers.push(( - "Autocrypt", - mail_builder::headers::raw::Raw::new(aheader).into(), - )); - } - if self.pre_message_mode == PreMessageMode::Post { headers.push(( "Chat-Is-Post-Message", @@ -973,8 +1231,6 @@ impl MimeFactory { )); } - let is_encrypted = self.will_be_encrypted(); - // Add ephemeral timer for non-MDN messages. // For MDNs it does not matter because they are not visible // and ignored by the receiver. @@ -988,18 +1244,76 @@ impl MimeFactory { } } - let is_securejoin_message = if let Loaded::Message { msg, .. } = &self.loaded { - msg.param.get_cmd() == SystemMessage::SecurejoinMessage - } else { - false + Ok(headers) + } + + /// Helper function render the messages that are not queued. + /// + /// Used for MDNs because their payload is rendered right before sending. + pub async fn render(self, context: &Context) -> Result { + let timestamp = self.timestamp; + let from_addr = context.get_primary_self_addr().await?; + let public_key = key::load_self_public_key(context).await?; + let secret_key = key::load_self_secret_key(context).await?; + let (queued_mail, side_effects) = Box::pin(self.pre_render(context)).await?; + let rendered_mail = render_queued_mail( + queued_mail, + &public_key, + &secret_key, + from_addr, + timestamp, + side_effects, + )?; + Ok(rendered_mail) + } + + /// Consumes a `MimeFactory` and renders it into a message which is then stored in + /// `smtp`-table to be used by the SMTP loop + #[expect(clippy::arithmetic_side_effects)] + pub(crate) async fn pre_render( + mut self, + context: &Context, + ) -> Result<(QueuedMail, RenderSideEffects)> { + let rfc724_mid = match &self.loaded { + Loaded::Message { msg, .. } => match &self.pre_message_mode { + PreMessageMode::Pre { .. } => { + if msg.pre_rfc724_mid.is_empty() { + create_outgoing_rfc724_mid() + } else { + msg.pre_rfc724_mid.clone() + } + } + _ => msg.rfc724_mid.clone(), + }, + Loaded::Mdn { .. } => create_outgoing_rfc724_mid(), }; + let subject_str = self.subject_str(context).await?; + let mut headers = self.render_headers(context, &subject_str).await?; + + let grpimage = self.grpimage(); + + let is_encrypted = self.will_be_encrypted(); + + let last_added_location_timestamp; + let avatar_is_attached; + let sync_ids_to_delete; + let message: MimePart<'static> = match &self.loaded { Loaded::Message { msg, .. } => { let msg = msg.clone(); - let (main_part, mut parts) = self + let RenderedMessage { + main_part, + mut parts, + last_added_location_timestamp: tmp_last_added_location_timestamp, + avatar_is_attached: tmp_avatar_is_attached, + sync_ids_to_delete: tmp_sync_ids_to_delete, + } = self .render_message(context, &mut headers, &grpimage, is_encrypted) .await?; + last_added_location_timestamp = tmp_last_added_location_timestamp; + avatar_is_attached = tmp_avatar_is_attached; + sync_ids_to_delete = tmp_sync_ids_to_delete; if parts.is_empty() { // Single part, render as regular message. main_part @@ -1016,29 +1330,49 @@ impl MimeFactory { } } } - Loaded::Mdn { .. } => self.render_mdn()?, + Loaded::Mdn { .. } => { + last_added_location_timestamp = None; + avatar_is_attached = false; + sync_ids_to_delete = None; + self.render_mdn()? + } }; - let HeadersByConfidentiality { - mut unprotected_headers, - hidden_headers, - protected_headers, - } = group_headers_by_confidentiality( - headers, - &self.from_addr, - self.timestamp, - is_encrypted, - is_securejoin_message, - ); + let should_attach_pubkey = !self.should_skip_autocrypt(); + let is_post_message = self.pre_message_mode == PreMessageMode::Post; + let side_effects = RenderSideEffects { + avatar_is_attached, + sync_ids_to_delete, + last_added_location_timestamp, + subject: subject_str, + }; - let outer_message = if let Some(encryption_pubkeys) = self.encryption_pubkeys { - let mut message = add_headers_to_encrypted_part( - message, - &unprotected_headers, - hidden_headers, - protected_headers, - ); + // Disable compression for SecureJoin to ensure + // there are no compression side channels + // leaking information about the tokens. + let should_compress = match &self.loaded { + Loaded::Message { msg, .. } => msg.param.get_cmd() != SystemMessage::SecurejoinMessage, + Loaded::Mdn { .. } => true, + }; + + let shared_secret: Option = match &self.loaded { + Loaded::Message { chat, msg } if should_encrypt_with_broadcast_secret(msg, chat) => { + let secret = load_broadcast_secret(context, chat.id).await?; + if secret.is_none() { + // If there is no shared secret yet + // because this is an old broadcast channel, + // created before we had symmetric encryption, + // we show an error message. + let text = BROADCAST_INCOMPATIBILITY_MSG; + chat::add_info_msg(context, chat.id, text).await?; + bail!(text); + } + secret + } + _ => None, + }; + if let Some(ref encryption_pubkeys) = self.encryption_pubkeys { // Add gossip headers in chats with multiple recipients let multiple_recipients = encryption_pubkeys.len() > 1 || context.get_config_bool(Config::BccSelf).await?; @@ -1049,10 +1383,10 @@ impl MimeFactory { match &self.loaded { Loaded::Message { chat, msg } => { if !should_hide_recipients(msg, chat) { - for (addr, key) in &encryption_pubkeys { + for (addr, key) in encryption_pubkeys { let fingerprint = key.dc_fingerprint().hex(); let cmd = msg.param.get_cmd(); - if self.pre_message_mode == PreMessageMode::Post { + if is_post_message { continue; } @@ -1105,10 +1439,10 @@ impl MimeFactory { } .to_string(); - message = message.header( + headers.push(( "Autocrypt-Gossip", - mail_builder::headers::raw::Raw::new(header), - ); + mail_builder::headers::raw::Raw::new(header).into(), + )); context .sql @@ -1127,73 +1461,34 @@ impl MimeFactory { // Never gossip in MDNs. } } + } - // Disable compression for SecureJoin to ensure - // there are no compression side channels - // leaking information about the tokens. - let compress = match &self.loaded { - Loaded::Message { msg, .. } => { - msg.param.get_cmd() != SystemMessage::SecurejoinMessage - } - Loaded::Mdn { .. } => true, - }; - - let shared_secret: Option = match &self.loaded { - Loaded::Message { chat, msg } - if should_encrypt_with_broadcast_secret(msg, chat) => - { - let secret = load_broadcast_secret(context, chat.id).await?; - if secret.is_none() { - // If there is no shared secret yet - // because this is an old broadcast channel, - // created before we had symmetric encryption, - // we show an error message. - let text = BROADCAST_INCOMPATIBILITY_MSG; - chat::add_info_msg(context, chat.id, text).await?; - bail!(text); - } - secret - } - _ => None, - }; - - let encrypted = if let Some(shared_secret) = shared_secret { - let sign = true; - encrypt_helper - .encrypt_symmetrically(context, &shared_secret, message, compress, sign) - .await? - } else { - // Asymmetric encryption + let is_encrypted = self.will_be_encrypted(); + let is_securejoin_message = if let Loaded::Message { msg, .. } = &self.loaded { + msg.param.get_cmd() == SystemMessage::SecurejoinMessage + } else { + false + }; - // Use SEIPDv2 if all recipients support it. - let seipd_version = if encryption_pubkeys - .iter() - .all(|(_addr, pubkey)| pubkey_supports_seipdv2(pubkey)) - { - SeipdVersion::V2 - } else { - SeipdVersion::V1 - }; + let display_name = if is_securejoin_message && !is_encrypted { + // Unencrypted securejoin messages should _not_ include the display name. + "".to_string() + } else { + self.from_displayname.clone() + }; - // Encrypt to self unconditionally, - // even for a single-device setup. - let mut encryption_keyring = vec![encrypt_helper.public_key.clone()]; - encryption_keyring - .extend(encryption_pubkeys.iter().map(|(_addr, key)| (*key).clone())); + let is_mdn = matches!(self.loaded, Loaded::Mdn { .. }); + let should_sign = true; - encrypt_helper - .encrypt( - context, - encryption_keyring, - message, - compress, - seipd_version, - ) - .await? - }; + let HeadersByConfidentiality { + mut unprotected_headers, + hidden_headers, + protected_headers, + } = group_headers_by_confidentiality(headers); - wrap_encrypted_part(encrypted) - } else if matches!(self.loaded, Loaded::Mdn { .. }) { + let message = if self.encryption_pubkeys.is_some() { + add_headers_to_encrypted_part(message, hidden_headers, protected_headers) + } else if is_mdn { // Never add outer multipart/mixed wrapper to MDN // as multipart/report Content-Type is used to recognize MDNs // by Delta Chat receiver and Chatmail servers @@ -1209,6 +1504,10 @@ impl MimeFactory { .fold(message, |message, (header, value)| { message.header(header, value) }); + let message = message.header( + "Message-ID", + mail_builder::headers::message_id::MessageId::new(rfc724_mid.clone()), + ); let message = MimePart::new("multipart/mixed", vec![message]); let message = protected_headers .iter() @@ -1221,24 +1520,26 @@ impl MimeFactory { HashSet::from_iter(protected_headers.iter().map(|(header, _value)| *header)); unprotected_headers.retain(|(header, _value)| !protected.contains(header)); - message + unprotected_headers + .iter() + .cloned() + .fold(message, |message, (header, value)| { + message.header(header, value) + }) }; + let raw_message = part_to_vec(message); - let MimeFactory { - last_added_location_timestamp, - .. - } = self; - - let message = render_outer_message(unprotected_headers, outer_message); - - Ok(RenderedEmail { - message, - is_encrypted, - last_added_location_timestamp, - sync_ids_to_delete: self.sync_ids_to_delete, + let queued_email = QueuedMail { + raw_message, rfc724_mid, - subject: subject_str, - }) + display_name, + encryption_pubkeys: self.encryption_pubkeys.clone(), + shared_secret, + should_attach_pubkey, + should_sign, + should_compress, + }; + Ok((queued_email, side_effects)) } /// Returns MIME part with a `message.kml` attachment. @@ -1278,12 +1579,12 @@ impl MimeFactory { } async fn render_message( - &mut self, + &self, context: &Context, headers: &mut Vec<(&'static str, HeaderType<'static>)>, grpimage: &Option, is_encrypted: bool, - ) -> Result<(MimePart<'static>, Vec>)> { + ) -> Result { let Loaded::Message { chat, msg } = &self.loaded else { bail!("Attempt to render MDN as a message"); }; @@ -1775,21 +2076,25 @@ impl MimeFactory { parts.push(msg_kml_part); } - if !matches!(self.pre_message_mode, PreMessageMode::Pre { .. }) - && location::is_sending_to_chat(context, msg.chat_id).await? - && let Some((part, timestamp)) = self.get_location_kml_part(context).await? - { - parts.push(part); - self.last_added_location_timestamp = Some(timestamp); - } + let last_added_location_timestamp = + if !matches!(self.pre_message_mode, PreMessageMode::Pre { .. }) + && location::is_sending_to_chat(context, msg.chat_id).await? + && let Some((part, timestamp)) = self.get_location_kml_part(context).await? + { + parts.push(part); + Some(timestamp) + } else { + None + }; + let mut sync_ids_to_delete = None; // 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 { 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())); - self.sync_ids_to_delete = Some(ids.to_string()); + sync_ids_to_delete = Some(ids.to_string()); } else if command == SystemMessage::WebxdcStatusUpdate { let json = msg.param.get(Param::Arg).unwrap_or_default(); parts.push(context.build_status_update_part(json)); @@ -1816,9 +2121,9 @@ impl MimeFactory { } } - self.attach_selfavatar = + let avatar_is_attached = self.attach_selfavatar && self.pre_message_mode != PreMessageMode::Post; - if self.attach_selfavatar { + if avatar_is_attached { match context.get_config(Config::Selfavatar).await? { Some(path) => match build_avatar_file(context, &path).await { Ok(avatar) => headers.push(( @@ -1834,7 +2139,13 @@ impl MimeFactory { } } - Ok((main_part, parts)) + Ok(RenderedMessage { + main_part, + parts, + last_added_location_timestamp, + avatar_is_attached, + sync_ids_to_delete, + }) } /// Render an MDN @@ -1903,23 +2214,6 @@ impl MimeFactory { } } -/// Stores the unprotected headers on the outer message, and renders it. -pub(crate) fn render_outer_message( - unprotected_headers: Vec<(&'static str, HeaderType<'static>)>, - outer_message: MimePart<'static>, -) -> String { - let outer_message = unprotected_headers - .into_iter() - .fold(outer_message, |message, (header, value)| { - message.header(header, value) - }); - - let mut buffer = Vec::new(); - let cursor = Cursor::new(&mut buffer); - outer_message.clone().write_part(cursor).ok(); - String::from_utf8_lossy(&buffer).to_string() -} - /// Takes the encrypted part, wraps it in a MimePart, /// and sets the appropriate Content-Type for the outer message pub(crate) fn wrap_encrypted_part(encrypted: String) -> MimePart<'static> { @@ -1936,38 +2230,18 @@ pub(crate) fn wrap_encrypted_part(encrypted: String) -> MimePart<'static> { fn add_headers_to_encrypted_part( message: MimePart<'static>, - unprotected_headers: &[(&'static str, HeaderType<'static>)], hidden_headers: Vec<(&'static str, HeaderType<'static>)>, protected_headers: Vec<(&'static str, HeaderType<'static>)>, ) -> MimePart<'static> { // Store protected headers in the inner message. - let message = protected_headers - .into_iter() - .fold(message, |message, (header, value)| { - message.header(header, value) - }); - // Add hidden headers to encrypted payload. - let mut message: MimePart<'static> = hidden_headers + let mut message: MimePart<'static> = protected_headers .into_iter() + .chain(hidden_headers) .fold(message, |message, (header, value)| { message.header(header, value) }); - message = unprotected_headers - .iter() - // Structural headers shouldn't be added as "HP-Outer". They are defined in - // . - .filter(|(name, _)| { - !(name.eq_ignore_ascii_case("mime-version") - || name.eq_ignore_ascii_case("content-type") - || name.eq_ignore_ascii_case("content-transfer-encoding") - || name.eq_ignore_ascii_case("content-disposition")) - }) - .fold(message, |message, (name, value)| { - message.header(format!("HP-Outer: {name}"), value.clone()) - }); - // Set the appropriate Content-Type for the inner message for (h, v) in &mut message.headers { if h == "Content-Type" @@ -2022,84 +2296,26 @@ struct HeadersByConfidentiality { /// See [`HeadersByConfidentiality`] for more info. fn group_headers_by_confidentiality( headers: Vec<(&'static str, HeaderType<'static>)>, - from_addr: &str, - timestamp: i64, - is_encrypted: bool, - is_securejoin_message: bool, ) -> HeadersByConfidentiality { let mut unprotected_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new(); let mut hidden_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new(); let mut protected_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new(); - // MIME header . - unprotected_headers.push(( - "MIME-Version", - mail_builder::headers::raw::Raw::new("1.0").into(), - )); - for header @ (original_header_name, _header_value) in &headers { let header_name = original_header_name.to_lowercase(); - if header_name == "message-id" { - unprotected_headers.push(header.clone()); - hidden_headers.push(header.clone()); - } else if is_hidden(&header_name) { - hidden_headers.push(header.clone()); - } else if header_name == "from" { - // Unencrypted securejoin messages should _not_ include the display name: - if is_encrypted || !is_securejoin_message { - protected_headers.push(header.clone()); - } + debug_assert_ne!(header_name, "from"); + debug_assert_ne!(header_name, "message-id"); + debug_assert_ne!(header_name, "autocrypt"); + debug_assert_ne!(header_name, "date"); - unprotected_headers.push(( - original_header_name, - Address::new_address(None::<&'static str>, from_addr.to_string()).into(), - )); + if is_hidden(&header_name) { + hidden_headers.push(header.clone()); } else if header_name == "to" { protected_headers.push(header.clone()); - if is_encrypted { - unprotected_headers.push(("To", hidden_recipients().into())); - } else { - unprotected_headers.push(header.clone()); - } + unprotected_headers.push(("To", hidden_recipients().into())); } else if header_name == "chat-broadcast-secret" { - if is_encrypted { - protected_headers.push(header.clone()); - } - } else if is_encrypted && header_name == "date" { protected_headers.push(header.clone()); - - // Randomized date goes to unprotected header. - // - // We cannot just send "Thu, 01 Jan 1970 00:00:00 +0000" - // or omit the header because GMX then fails with - // - // host mx00.emig.gmx.net[212.227.15.9] said: - // 554-Transaction failed - // 554-Reject due to policy restrictions. - // 554 For explanation visit https://postmaster.gmx.net/en/case?... - // (in reply to end of DATA command) - // - // and the explanation page says - // "The time information deviates too much from the actual time". - // - // We also limit the range to 6 days (518400 seconds) - // because with a larger range we got - // error "500 Date header far in the past/future" - // which apparently originates from Symantec Messaging Gateway - // and means the message has a Date that is more - // than 7 days in the past: - // - let timestamp_offset = rand::random_range(0..518400); - let protected_timestamp = timestamp.saturating_sub(timestamp_offset); - let unprotected_date = - chrono::DateTime::::from_timestamp(protected_timestamp, 0) - .unwrap() - .to_rfc2822(); - unprotected_headers.push(( - "Date", - mail_builder::headers::raw::Raw::new(unprotected_date).into(), - )); - } else if is_encrypted { + } else { protected_headers.push(header.clone()); match header_name.as_str() { @@ -2116,8 +2332,6 @@ fn group_headers_by_confidentiality( // Other headers are removed from unprotected part. } } - } else { - unprotected_headers.push(header.clone()) } } HeadersByConfidentiality { @@ -2238,17 +2452,17 @@ pub(crate) async fn render_symm_encrypted_securejoin_message( context: &Context, step: &str, rfc724_mid: &str, - attach_self_pubkey: bool, + should_attach_pubkey: bool, auth: &str, shared_secret: &str, ) -> Result { info!(context, "Sending secure-join message {step:?}."); - let mut headers = Vec::<(&'static str, HeaderType<'static>)>::new(); + let timestamp = time(); - let from_addr = context.get_primary_self_addr().await?; - let from = new_address_with_name("", from_addr.to_string()); - headers.push(("From", from.into())); + let message: MimePart<'static> = MimePart::new("text/plain", "Secure-Join"); + + let mut headers = Vec::<(&'static str, HeaderType<'static>)>::new(); let to: Vec> = vec![hidden_recipients()]; headers.push(( @@ -2261,17 +2475,6 @@ pub(crate) async fn render_symm_encrypted_securejoin_message( mail_builder::headers::text::Text::new("Secure-Join".to_string()).into(), )); - let timestamp = time(); - let date = chrono::DateTime::::from_timestamp(timestamp, 0) - .unwrap() - .to_rfc2822(); - headers.push(("Date", mail_builder::headers::raw::Raw::new(date).into())); - - headers.push(( - "Message-ID", - mail_builder::headers::message_id::MessageId::new(rfc724_mid.to_string()).into(), - )); - // Automatic Response headers if context.get_config_bool(Config::Bot).await? { headers.push(( @@ -2280,16 +2483,6 @@ pub(crate) async fn render_symm_encrypted_securejoin_message( )); } - let encrypt_helper = EncryptHelper::new(context).await?; - - if attach_self_pubkey { - let aheader = encrypt_helper.get_aheader().to_string(); - headers.push(( - "Autocrypt", - mail_builder::headers::raw::Raw::new(aheader).into(), - )); - } - headers.push(( "Secure-Join", mail_builder::headers::raw::Raw::new(step.to_string()).into(), @@ -2300,46 +2493,63 @@ pub(crate) async fn render_symm_encrypted_securejoin_message( mail_builder::headers::text::Text::new(auth.to_string()).into(), )); - let message: MimePart<'static> = MimePart::new("text/plain", "Secure-Join"); - - let is_encrypted = true; - let is_securejoin_message = true; let HeadersByConfidentiality { - unprotected_headers, hidden_headers, protected_headers, - } = group_headers_by_confidentiality( - headers, - &from_addr, - timestamp, - is_encrypted, - is_securejoin_message, - ); + .. + } = group_headers_by_confidentiality(headers); + + let message = add_headers_to_encrypted_part(message, hidden_headers, protected_headers); + + // Disable compression for SecureJoin to ensure + // there are no compression side channels + // leaking information about the tokens. + let should_compress = false; + + // Only sign the message if we attach the pubkey. + let should_sign = should_attach_pubkey; + + let raw_message = part_to_vec(message); + + let queued_mail = QueuedMail { + raw_message, + display_name: String::new(), + rfc724_mid: rfc724_mid.to_string(), + encryption_pubkeys: Some(vec![]), + shared_secret: Some(shared_secret.to_string()), + should_attach_pubkey, + should_sign, + should_compress, + }; - let outer_message = { - let message = add_headers_to_encrypted_part( - message, - &unprotected_headers, - hidden_headers, - protected_headers, - ); + let public_key = key::load_self_public_key(context).await?; + let secret_key = key::load_self_secret_key(context).await?; - // Disable compression for SecureJoin to ensure - // there are no compression side channels - // leaking information about the tokens. - let compress = false; - // Only sign the message if we attach the pubkey. - let sign = attach_self_pubkey; - let encrypted = encrypt_helper - .encrypt_symmetrically(context, shared_secret, message, compress, sign) - .await?; + let side_effects = RenderSideEffects { + subject: "Secure-Join".to_string(), - wrap_encrypted_part(encrypted) + ..Default::default() }; - let message = render_outer_message(unprotected_headers, outer_message); + let from_addr = context.get_primary_self_addr().await?; + let rendered_mail = render_queued_mail( + queued_mail, + &public_key, + &secret_key, + from_addr, + timestamp, + side_effects, + )?; + + Ok(rendered_mail.message) +} - Ok(message) +/// Renders MIME part into a vector. +pub(crate) fn part_to_vec(message: MimePart<'static>) -> Vec { + let mut raw_message = Vec::new(); + let cursor = Cursor::new(&mut raw_message); + message.write_part(cursor).ok(); + raw_message } #[cfg(test)] diff --git a/src/pgp.rs b/src/pgp.rs index 8821d36105..29fd31307f 100644 --- a/src/pgp.rs +++ b/src/pgp.rs @@ -21,7 +21,6 @@ use pgp::types::{ }; use rand_old::{Rng as _, thread_rng}; use sha2::Sha256; -use tokio::runtime::Handle; use crate::key::{DcKey, Fingerprint}; @@ -109,97 +108,93 @@ pub enum SeipdVersion { /// Encrypts `plain` text using `public_keys_for_encryption` /// and signs it using `private_key_for_signing`. #[expect(clippy::arithmetic_side_effects)] -pub async fn pk_encrypt( +pub fn pk_encrypt( plain: Vec, public_keys_for_encryption: Vec, private_key_for_signing: SignedSecretKey, compress: bool, seipd_version: SeipdVersion, ) -> Result { - Handle::current() - .spawn_blocking(move || { - let mut rng = thread_rng(); + let mut rng = thread_rng(); - let pkeys = public_keys_for_encryption - .iter() - .filter_map(select_pk_for_encryption); - let subpkts = { - let mut hashed = Vec::with_capacity(1 + public_keys_for_encryption.len() + 1); - hashed.push(Subpacket::critical(SubpacketData::SignatureCreationTime( - pgp::types::Timestamp::now(), - ))?); - for key in &public_keys_for_encryption { - let data = SubpacketData::IntendedRecipientFingerprint(key.fingerprint()); - let subpkt = match private_key_for_signing.version() < KeyVersion::V6 { - true => Subpacket::regular(data)?, - false => Subpacket::critical(data)?, - }; - hashed.push(subpkt); - } - hashed.push(Subpacket::regular(SubpacketData::IssuerFingerprint( - private_key_for_signing.fingerprint(), - ))?); - let mut unhashed = vec![]; - if private_key_for_signing.version() <= KeyVersion::V4 { - unhashed.push(Subpacket::regular(SubpacketData::IssuerKeyId( - private_key_for_signing.legacy_key_id(), - ))?); - } - SubpacketConfig::UserDefined { hashed, unhashed } + let pkeys = public_keys_for_encryption + .iter() + .filter_map(select_pk_for_encryption); + let subpkts = { + let mut hashed = Vec::with_capacity(1 + public_keys_for_encryption.len() + 1); + hashed.push(Subpacket::critical(SubpacketData::SignatureCreationTime( + pgp::types::Timestamp::now(), + ))?); + for key in &public_keys_for_encryption { + let data = SubpacketData::IntendedRecipientFingerprint(key.fingerprint()); + let subpkt = match private_key_for_signing.version() < KeyVersion::V6 { + true => Subpacket::regular(data)?, + false => Subpacket::critical(data)?, }; + hashed.push(subpkt); + } + hashed.push(Subpacket::regular(SubpacketData::IssuerFingerprint( + private_key_for_signing.fingerprint(), + ))?); + let mut unhashed = vec![]; + if private_key_for_signing.version() <= KeyVersion::V4 { + unhashed.push(Subpacket::regular(SubpacketData::IssuerKeyId( + private_key_for_signing.legacy_key_id(), + ))?); + } + SubpacketConfig::UserDefined { hashed, unhashed } + }; - let msg = MessageBuilder::from_bytes("", plain); - let encoded_msg = match seipd_version { - SeipdVersion::V1 => { - let mut msg = msg.seipd_v1(&mut rng, SYMMETRIC_KEY_ALGORITHM); - - for pkey in pkeys { - msg.encrypt_to_key_anonymous(&mut rng, &pkey)?; - } + let msg = MessageBuilder::from_bytes("", plain); + let encoded_msg = match seipd_version { + SeipdVersion::V1 => { + let mut msg = msg.seipd_v1(&mut rng, SYMMETRIC_KEY_ALGORITHM); - let hash_algorithm = private_key_for_signing.hash_alg(); - msg.sign_with_subpackets( - &*private_key_for_signing, - Password::empty(), - hash_algorithm, - subpkts, - ); - if compress { - msg.compression(CompressionAlgorithm::ZLIB); - } + for pkey in pkeys { + msg.encrypt_to_key_anonymous(&mut rng, &pkey)?; + } - msg.to_armored_string(&mut rng, Default::default())? - } - SeipdVersion::V2 => { - let mut msg = msg.seipd_v2( - &mut rng, - SYMMETRIC_KEY_ALGORITHM, - AeadAlgorithm::Ocb, - ChunkSize::C8KiB, - ); + let hash_algorithm = private_key_for_signing.hash_alg(); + msg.sign_with_subpackets( + &*private_key_for_signing, + Password::empty(), + hash_algorithm, + subpkts, + ); + if compress { + msg.compression(CompressionAlgorithm::ZLIB); + } - for pkey in pkeys { - msg.encrypt_to_key_anonymous(&mut rng, &pkey)?; - } + msg.to_armored_string(&mut rng, Default::default())? + } + SeipdVersion::V2 => { + let mut msg = msg.seipd_v2( + &mut rng, + SYMMETRIC_KEY_ALGORITHM, + AeadAlgorithm::Ocb, + ChunkSize::C8KiB, + ); + + for pkey in pkeys { + msg.encrypt_to_key_anonymous(&mut rng, &pkey)?; + } - let hash_algorithm = private_key_for_signing.hash_alg(); - msg.sign_with_subpackets( - &*private_key_for_signing, - Password::empty(), - hash_algorithm, - subpkts, - ); - if compress { - msg.compression(CompressionAlgorithm::ZLIB); - } + let hash_algorithm = private_key_for_signing.hash_alg(); + msg.sign_with_subpackets( + &*private_key_for_signing, + Password::empty(), + hash_algorithm, + subpkts, + ); + if compress { + msg.compression(CompressionAlgorithm::ZLIB); + } - msg.to_armored_string(&mut rng, Default::default())? - } - }; + msg.to_armored_string(&mut rng, Default::default())? + } + }; - Ok(encoded_msg) - }) - .await? + Ok(encoded_msg) } /// Returns fingerprints @@ -485,7 +480,7 @@ mod tests { config::Config, decrypt, key::{load_self_public_key, self_fingerprint, store_self_keypair}, - mimefactory::{render_outer_message, wrap_encrypted_part}, + mimefactory::{part_to_vec, wrap_encrypted_part}, test_utils::{TestContext, TestContextManager, alice_keypair, bob_keypair}, token, }; @@ -511,8 +506,8 @@ mod tests { store_self_keypair(t, secret_key).await?; let mime_message = wrap_encrypted_part(bytes.try_into().unwrap()); - let rendered = render_outer_message(vec![], mime_message); - let parsed = mailparse::parse_mail(rendered.as_bytes())?; + let rendered = part_to_vec(mime_message); + let parsed = mailparse::parse_mail(&rendered)?; let (decrypted, _fp) = decrypt::decrypt(t, &parsed).await?.unwrap(); Ok(decrypted) } @@ -585,7 +580,6 @@ mod tests { compress, SeipdVersion::V2, ) - .await .unwrap() }) .await @@ -776,8 +770,7 @@ mod tests { KEYS.alice_secret.clone(), compress, SeipdVersion::V2, - ) - .await?; + )?; // Trying to decrypt it should fail with an OK error message: let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?; diff --git a/src/reaction.rs b/src/reaction.rs index 279c1afa1f..3ba72c5183 100644 --- a/src/reaction.rs +++ b/src/reaction.rs @@ -967,8 +967,7 @@ Content-Transfer-Encoding: base64\r alice_secret_key, compress, SeipdVersion::V2, - ) - .await?; + )?; let boundary = "boundary123"; let rcvd_mail = format!( diff --git a/src/test_utils.rs b/src/test_utils.rs index f5f245c8a3..a72b3187c9 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -22,6 +22,7 @@ use tokio::runtime::Handle; use tokio::{fs, task}; use uuid::Uuid; +use crate::aheader::{Aheader, EncryptPreference}; use crate::chat::{ self, Chat, ChatId, ChatIdBlocked, MessageListOptions, add_to_chat_contacts_table, create_group, }; @@ -33,7 +34,6 @@ use crate::contact::{ Contact, ContactId, Modifier, Origin, import_vcard, make_vcard, mark_contact_id_as_verified, }; use crate::context::Context; -use crate::e2ee::EncryptHelper; use crate::events::{Event, EventEmitter, EventType, Events}; use crate::key::{self, DcKey, self_fingerprint}; use crate::log::warn; @@ -1240,8 +1240,15 @@ pub async fn encrypt_raw_message( receivers: &[&TestContext], payload: &[u8], ) -> Result { - let encryption_helper = EncryptHelper::new(context).await?; - let mut encryption_keyring = vec![encryption_helper.public_key.clone()]; + let public_key = key::load_self_public_key(context).await?; + let aheader = Aheader { + addr: context.get_primary_self_addr().await?, + public_key: public_key.clone(), + prefer_encrypt: EncryptPreference::Mutual, + verified: false, + }; + + let mut encryption_keyring = vec![public_key.clone()]; for receiver in receivers { encryption_keyring.push(key::load_self_public_key(receiver).await?); @@ -1250,18 +1257,17 @@ pub async fn encrypt_raw_message( let from = context.get_primary_self_addr().await?; let compress = false; - let mut cleartext = format!("Autocrypt: {}", encryption_helper.get_aheader()).into_bytes(); + let mut cleartext = format!("Autocrypt: {aheader}").into_bytes(); cleartext.extend_from_slice(b"\r\n"); cleartext.extend_from_slice(payload); - let encrypted_payload = encryption_helper - .encrypt_raw( - context, - encryption_keyring, - cleartext, - compress, - SeipdVersion::V2, - ) - .await?; + let sign_key = key::load_self_secret_key(context).await?; + let encrypted_payload = crate::pgp::pk_encrypt( + cleartext, + encryption_keyring, + sign_key, + compress, + SeipdVersion::V2, + )?; let boundary = Uuid::new_v4(); let res = format!( From 598ac45eeca9467c8062e2392db4a2da609bebc0 Mon Sep 17 00:00:00 2001 From: link2xt Date: Tue, 30 Jun 2026 15:51:11 +0000 Subject: [PATCH 2/2] fixup: simplify should_compress calculation --- src/mimefactory.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/mimefactory.rs b/src/mimefactory.rs index fa4bebe02e..47b36909f9 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -1347,13 +1347,15 @@ impl MimeFactory { subject: subject_str, }; + let is_securejoin_message = match &self.loaded { + Loaded::Message { msg, .. } => msg.param.get_cmd() == SystemMessage::SecurejoinMessage, + Loaded::Mdn { .. } => false, + }; + // Disable compression for SecureJoin to ensure // there are no compression side channels // leaking information about the tokens. - let should_compress = match &self.loaded { - Loaded::Message { msg, .. } => msg.param.get_cmd() != SystemMessage::SecurejoinMessage, - Loaded::Mdn { .. } => true, - }; + let should_compress = !is_securejoin_message; let shared_secret: Option = match &self.loaded { Loaded::Message { chat, msg } if should_encrypt_with_broadcast_secret(msg, chat) => { @@ -1464,11 +1466,6 @@ impl MimeFactory { } let is_encrypted = self.will_be_encrypted(); - let is_securejoin_message = if let Loaded::Message { msg, .. } = &self.loaded { - msg.param.get_cmd() == SystemMessage::SecurejoinMessage - } else { - false - }; let display_name = if is_securejoin_message && !is_encrypted { // Unencrypted securejoin messages should _not_ include the display name.