//! Compose view-model projection and recipient/file helpers. use super::*; use flectar_mail_core::sender_identities::{is_own_address, reply_sender}; #[derive(Clone, Debug, PartialEq, Eq)] pub(super) struct ComposeIntent { pub mode: String, pub in_reply_to_message_id: Option, /// The Message-ID a reply answers when that message is not stored here. pub in_reply_to_header: Option, pub draft_id: Option, } impl Default for ComposeIntent { fn default() -> Self { Self { mode: "new".to_owned(), in_reply_to_message_id: None, in_reply_to_header: None, draft_id: None, } } } #[derive(Clone, Debug, PartialEq, Eq)] pub(super) struct PreparedMessageCompose { pub account_id: i64, pub sender_email: String, pub to: String, pub cc: String, pub subject: String, /// What the editor starts with. pub body: String, /// A message edited as new keeps its formatting when this is its own. pub saved: Option, /// The original a reply or forward carries below the editor. pub quote: Option, pub intent: ComposeIntent, } /// A message as it was sent: its HTML, and the plain text that has to say /// the same for the HTML's formatting to be kept. #[derive(Clone, Debug, PartialEq, Eq)] pub(super) struct SavedBody { pub html: String, pub text: String, } /// Prepares a reply or forward, or a message edited as new. `translate` /// renders the quotation headers in the interface language, as mail clients /// write them for their user. pub(super) fn prepare_message_compose( source: &ComposeSource, action: &str, rendered_plain_text: &str, translate: impl Fn(UiMessage) -> String, ) -> Result { if !matches!(action, "reply" | "reply_all" | "forward" | "edit_as_new") { return Err(format!("unsupported compose action: {action}")); } let original_body = if rendered_plain_text.trim().is_empty() { source.text_body.as_str() } else { rendered_plain_text }; let sender = format_address(&source.from); let sent_at = chrono::Local .timestamp_millis_opt(source.date) .single() .map(|date| date_format::current().date_time(DateStyle::WeekdayMedium, date.naive_local())); // Thunderbird's Edit As New Message: the message as it was, to send // again or anew, from the address that sent it when that is one's own. if action == "edit_as_new" { let sender_email = if is_own_address(&source.sender_identities, &source.from.email) { source.from.email.clone() } else { reply_sender( &source.sender_identities, source.to.iter().chain(source.cc.iter()), ) .unwrap_or_else(|| source.default_sender_email.clone()) }; return Ok(PreparedMessageCompose { account_id: source.account_id, sender_email, to: join_addresses(&source.to), cc: join_addresses(&source.cc), subject: source.subject.clone(), body: original_body.trim_end().to_owned(), saved: source.html_body.clone().map(|html| SavedBody { html, text: source.text_body.clone(), }), quote: None, intent: ComposeIntent::default(), }); } if action == "forward" { let mut header = vec![format!( "---------- {} ----------", translate(UiMessage::plain("Forwarded message")) )]; let lines = [ Some(UiMessage::detail("From: {}", &sender)), sent_at .as_ref() .map(|sent_at| UiMessage::detail("Date: {}", sent_at)), Some(UiMessage::detail("Subject: {}", &source.subject)), Some(UiMessage::detail("To: {}", join_addresses(&source.to))), ]; header.extend(lines.into_iter().flatten().map(&translate)); let quote = ComposeQuote::forward(&header, original_body, source.html_body.as_deref()); // Like a reply, forward from the address the message reached. let sender_email = reply_sender( &source.sender_identities, source.to.iter().chain(source.cc.iter()), ) .unwrap_or_else(|| source.default_sender_email.clone()); return Ok(PreparedMessageCompose { account_id: source.account_id, sender_email, to: String::new(), cc: String::new(), subject: prefixed_subject(&source.subject, "Fwd"), body: String::new(), saved: None, quote: Some(quote), intent: ComposeIntent { mode: "forward".to_owned(), in_reply_to_message_id: None, in_reply_to_header: None, draft_id: None, }, }); } let is_own = |email: &str| is_own_address(&source.sender_identities, email); let sender_email = reply_sender( &source.sender_identities, source .to .iter() .chain(source.cc.iter()) .chain(std::iter::once(&source.from)), ) .unwrap_or_else(|| source.default_sender_email.clone()); let candidates = std::iter::once(&source.from) .filter(|address| !is_own(&address.email)) .chain(source.to.iter()) .chain(source.cc.iter().filter(|_| action == "reply_all")); let recipients = deduplicated_addresses(candidates, is_own); let primary = recipients .first() .ok_or_else(|| "the selected message has no reply recipient".to_owned())?; let cc = if action == "reply_all" { join_addresses(recipients[1..].iter().copied()) } else { String::new() }; let attribution = translate(match &sent_at { Some(sent_at) => UiMessage::arguments("On {}, {} wrote:", sent_at, &sender), None => UiMessage::detail("{} wrote:", &sender), }); let quote = ComposeQuote::reply(&attribution, original_body, source.html_body.as_deref()); Ok(PreparedMessageCompose { account_id: source.account_id, sender_email, to: format_address(primary), cc, subject: prefixed_subject(&source.subject, "Re"), body: String::new(), saved: None, quote: Some(quote), intent: ComposeIntent { mode: action.to_owned(), in_reply_to_message_id: Some(source.message_id), in_reply_to_header: None, draft_id: None, }, }) } pub(super) fn compose_addresses(addresses: &[flectar_mail_core::models::Address]) -> String { join_addresses(addresses) } fn deduplicated_addresses<'a>( addresses: impl IntoIterator, is_own: impl Fn(&str) -> bool, ) -> Vec<&'a flectar_mail_core::models::Address> { let mut seen = HashSet::new(); addresses .into_iter() .filter(|address| { let email = address.email.trim(); let email = if email.bytes().any(|byte| byte.is_ascii_uppercase()) { std::borrow::Cow::Owned(email.to_ascii_lowercase()) } else { std::borrow::Cow::Borrowed(email) }; !email.is_empty() && !is_own(&email) && seen.insert(email) }) .collect() } fn join_addresses<'a>( addresses: impl IntoIterator, ) -> String { let mut output = String::new(); append_addresses(&mut output, addresses); output } fn append_addresses<'a>( output: &mut String, addresses: impl IntoIterator, ) { for (index, address) in addresses.into_iter().enumerate() { if index > 0 { output.push_str(", "); } append_address(output, address); } } fn format_address(address: &flectar_mail_core::models::Address) -> String { let mut output = String::new(); append_address(&mut output, address); output } fn append_address(output: &mut String, address: &flectar_mail_core::models::Address) { match address .name .as_deref() .map(str::trim) .filter(|name| !name.is_empty()) { Some(name) => { let name = if name.contains([',', ';', '<', '>', '\r', '\n']) { std::borrow::Cow::Owned( name.chars() .filter(|character| { !matches!(character, ',' | ';' | '<' | '>' | '\r' | '\n') }) .collect::(), ) } else { std::borrow::Cow::Borrowed(name) }; output.push_str(name.trim()); output.push_str(" <"); output.push_str(&address.email); output.push('>'); } None => output.push_str(&address.email), } } fn prefixed_subject(subject: &str, prefix: &str) -> String { let subject = subject.trim(); let already_prefixed = match prefix { "Re" => subject .get(..3) .is_some_and(|value| value.eq_ignore_ascii_case("re:")), "Fwd" => { subject .get(..4) .is_some_and(|value| value.eq_ignore_ascii_case("fwd:")) || subject .get(..3) .is_some_and(|value| value.eq_ignore_ascii_case("fw:")) } _ => false, }; if already_prefixed { subject.to_owned() } else if subject.is_empty() { format!("{prefix}:") } else { format!("{prefix}: {subject}") } } pub(super) fn perform_selected_action( _app: &AppWindow, state: &Rc>, _runtime: &tokio::runtime::Handle, action: &str, ) -> Result<(), String> { let (selected_id, target, core, using_core) = { let state = state.borrow(); let selected = state .selected_id .and_then(|id| state.messages.iter().find(|message| message.id == id)); ( state.selected_id, selected.and_then(MailTarget::of), state.core.clone(), state.using_core, ) }; if !using_core { return Err("mail account is not ready".to_owned()); } let core = core.ok_or_else(|| "mail core is unavailable".to_owned())?; let target = target.ok_or_else(|| "no message is selected".to_owned())?; mail_work::enqueue( state, core, vec![( selected_id.unwrap(), target, mail_work::Operation::Action(action.to_owned()), )], ) } pub(super) fn format_file_size(size: u64) -> String { const KB: f64 = 1024.0; const MB: f64 = 1024.0 * 1024.0; if size < 1024 { format!("{size} B") } else if (size as f64) < MB { format!("{:.1} KB", size as f64 / KB) } else { format!("{:.1} MB", size as f64 / MB) } } pub(super) fn apply_compose_files(ui: &ComposerUi<'_>, files: &[ComposeFile]) { let rows = files .iter() .map(|file| { let kind = file .path .extension() .and_then(|extension| extension.to_str()) .filter(|extension| !extension.is_empty()) .map(str::to_uppercase) .unwrap_or_else(|| "FILE".to_owned()); ComposeAttachment { name: file.filename.clone().into(), detail: format!("{kind} · {}", format_file_size(file.size)).into(), } }) .collect::>(); let total = files.iter().map(|file| file.size).sum::(); ui.set_attachments(ModelRc::new(VecModel::from(rows))); ui.set_attachment_summary( if files.is_empty() { "".to_owned() } else { format!( "{} {} · {}", files.len(), if files.len() == 1 { "file" } else { "files" }, format_file_size(total), ) } .into(), ); } /// Where a composer is shown: its window's `ComposerUi`, the window itself /// for the scale factor, and whether that window paints at all. pub(super) struct ComposeView<'a> { pub(super) ui: ComposerUi<'a>, pub(super) window: &'a slint::Window, pub(super) suspended: bool, } pub(super) fn apply_rich_compose( view: &ComposeView<'_>, document: &RichComposeDocument, selection: ComposeSelection, editor: &mut LazyComposeEditor, ) { view.ui.set_body(document.text().into()); apply_rich_compose_state(&view.ui, document, selection); apply_compose_editor_surface(view, document, selection, editor); } pub(super) fn apply_compose_editor_surface( view: &ComposeView<'_>, document: &RichComposeDocument, selection: ComposeSelection, editor: &mut LazyComposeEditor, ) { let ui = &view.ui; if view.suspended || !ui.get_open() { return; } let width = ui.get_editor_width().max(1.0); let viewport_height = ui.get_editor_viewport_height().max(1.0); let (width, viewport_height) = if width <= 1.0 || viewport_height <= 1.0 { (640.0, 360.0) } else { (width, viewport_height) }; let rendered = editor.render( document, selection, width, viewport_height, ui.get_editor_scroll_y().max(0.0), view.window.scale_factor(), compose_editor_style(ui), ui.get_editor_preedit_text().as_str(), ); apply_rendered_compose_editor(ui, rendered); } pub(super) fn apply_rendered_compose_editor(ui: &ComposerUi<'_>, rendered: RenderedComposeEditor) { let started = crate::renderer::render_timings_enabled().then(std::time::Instant::now); let tiles = rendered .tiles .into_iter() .map(|tile| ComposeEditorTile { image: tile.image, y: tile.y, height: tile.height, }) .collect::>(); let tile_count = tiles.len(); let retained = ui.get_editor_tiles(); let replaced_model = if let Some(model) = retained .as_any() .downcast_ref::>() { crate::reconcile_model_rows_by(model, tiles, |tile| tile.y.to_bits(), PartialEq::eq); false } else { ui.set_editor_tiles(ModelRc::new(VecModel::from(tiles))); true }; ui.set_editor_content_height(rendered.content_height); ui.set_editor_caret_x(rendered.caret_x); ui.set_editor_caret_y(rendered.caret_y); ui.set_editor_caret_height(rendered.caret_height); if let Some(started) = started { eprintln!( "compose Slint model update: {:.2}ms tiles={tile_count} replaced_model={replaced_model}", started.elapsed().as_secs_f64() * 1000.0 ); } } pub(super) fn compose_editor_style(ui: &ComposerUi<'_>) -> ComposeEditorStyle { let selection = ui.get_editor_selection_color(); ComposeEditorStyle { text: ui.get_editor_text_color(), link: ui.get_editor_link_color(), selection: slint::Color::from_argb_u8( 140, selection.red(), selection.green(), selection.blue(), ), selected_text: ui.get_editor_selected_text_color(), misspelling: ui.get_editor_misspelling_color(), } } pub(super) fn apply_rich_compose_state( ui: &ComposerUi<'_>, document: &RichComposeDocument, selection: ComposeSelection, ) { let active = document.active_marks(); ui.set_selection_start(selection.start); ui.set_selection_end(selection.end); ui.set_bold_active(active.bold); ui.set_italic_active(active.italic); ui.set_underline_active(active.underline); ui.set_strike_active(active.strike); ui.set_code_active(active.code); ui.set_active_link_url(document.active_link().unwrap_or_default().into()); } pub(super) fn apply_compose_contacts( ui: &ComposerUi<'_>, contacts: &[flectar_mail_core::models::Address], ) { let rows = contacts .iter() .map(|contact| ComposeContact { name: contact.name.clone().unwrap_or_default().into(), email: contact.email.clone().into(), initials: contact_initials(contact).into(), }) .collect::>(); ui.set_contact_suggestions(ModelRc::new(VecModel::from(rows))); } pub(super) fn apply_email_templates( ui: &ComposerUi<'_>, templates: &[flectar_mail_core::models::Snippet], ) { let rows = templates .iter() .filter_map(|template| { Some(EmailTemplateRow { id: i32::try_from(template.id).ok()?, name: template.name.clone().into(), shortcut: template.shortcut.clone().unwrap_or_default().into(), subject: template.subject.clone().unwrap_or_default().into(), body_text: template.body_text.clone().into(), }) }) .collect::>(); ui.set_templates(ModelRc::new(VecModel::from(rows))); } pub(super) fn contact_initials(contact: &flectar_mail_core::models::Address) -> String { let source = contact .name .as_deref() .filter(|name| !name.trim().is_empty()) .unwrap_or(&contact.email); let mut initials = source .split_whitespace() .filter_map(|part| part.chars().next()) .take(2) .collect::(); if initials.is_empty() { initials.push('@'); } initials.to_uppercase() } pub(super) fn compose_recipient_query(recipients: &str) -> String { let token_start = recipients.rfind([',', ';', '\n']).map_or(0, |index| index + 1); recipients[token_start..].trim().to_owned() } pub(super) fn complete_compose_recipient( recipients: &str, contact: &flectar_mail_core::models::Address, ) -> String { if recipients .to_lowercase() .contains(&contact.email.to_lowercase()) { return recipients.to_owned(); } let token_start = recipients.rfind([',', ';', '\n']).map_or(0, |index| index + 1); let prefix = recipients[..token_start].trim_end(); let clean_name = contact .name .as_deref() .unwrap_or_default() .chars() .filter(|character| !matches!(character, ',' | ';' | '<' | '>')) .collect::(); let recipient = if clean_name.trim().is_empty() || clean_name.trim().eq_ignore_ascii_case(&contact.email) { contact.email.clone() } else { format!("{} <{}>", clean_name.trim(), contact.email) }; if prefix.is_empty() { format!("{recipient}, ") } else if prefix.ends_with([',', ';']) { format!("{prefix} {recipient}, ") } else { // A newline-separated prefix normalizes to a comma separator so the // completed address never glues onto the previous line. format!("{prefix}, {recipient}, ") } } /// The original's files once fetched: each one's path, name and size, or /// why it could not be had. #[cfg(test)] mod tests { use super::*; use flectar_mail_core::models::{ Address, SenderDeliveryStatus, SenderIdentity, SenderIdentitySource, }; fn english(message: UiMessage) -> String { message.english() } fn address(name: &str, email: &str) -> Address { Address { name: (!name.is_empty()).then(|| name.to_owned()), email: email.to_owned(), } } fn identity(email: &str, primary: bool, provider_default: bool) -> SenderIdentity { SenderIdentity { account_id: 7, email: email.to_owned(), display_name: None, reply_to_email: None, is_primary: primary, is_provider_default: provider_default, verification_status: "accepted".into(), last_synced_at: 1, source: SenderIdentitySource::Provider, delivery_status: SenderDeliveryStatus::Unknown, delivery_error: None, } } fn source() -> ComposeSource { ComposeSource { message_id: 91, account_id: 7, account_email: "alex@example.com".to_owned(), sender_identities: vec![ identity("alex@example.com", true, true), identity("work@example.net", false, false), ], default_sender_email: "alex@example.com".to_owned(), from: address("Maya", "maya@example.com"), to: vec![ address("Alex", "alex@example.com"), address("Jon", "jon@example.com"), ], cc: vec![ address("Jon Duplicate", "JON@example.com"), address("Lena", "lena@example.com"), ], subject: "Launch plan".to_owned(), date: 1_777_000_000_000, text_body: "First line\nSecond line".to_owned(), html_body: None, attachments: Vec::new(), } } #[test] fn reply_all_excludes_the_sender_account_and_deduplicates_recipients() { let prepared = prepare_message_compose(&source(), "reply_all", "", english).unwrap(); assert_eq!(prepared.account_id, 7); assert_eq!(prepared.sender_email, "alex@example.com"); assert_eq!(prepared.to, "Maya "); assert_eq!( prepared.cc, "Jon , Lena " ); assert_eq!(prepared.subject, "Re: Launch plan"); assert!(prepared.body.is_empty(), "the reply starts empty"); assert!( prepared .quote .unwrap() .plain .ends_with("wrote:\n> First line\n> Second line") ); assert_eq!(prepared.intent.in_reply_to_message_id, Some(91)); } #[test] fn reply_uses_the_alias_that_received_the_message_and_excludes_all_self_addresses() { let mut source = source(); source.to = vec![ address("Alex at work", "work@example.net"), address("Alex", "alex@example.com"), address("Jon", "jon@example.com"), ]; source.cc = vec![address("Work duplicate", "WORK@example.net")]; let prepared = prepare_message_compose(&source, "reply_all", "", english).unwrap(); assert_eq!(prepared.sender_email, "work@example.net"); assert_eq!(prepared.to, "Maya "); assert_eq!(prepared.cc, "Jon "); } #[test] fn replies_use_wildcard_and_subaddress_matches_and_exclude_them_from_reply_all() { let mut source = source(); source.sender_identities.push(identity("*@team.example", false, false)); source.to = vec![ address("Support", "support@team.example"), address("Alex", "alex+launch@example.com"), address("Jon", "jon@example.com"), ]; source.cc = Vec::new(); let prepared = prepare_message_compose(&source, "reply_all", "", english).unwrap(); assert_eq!(prepared.sender_email, "support@team.example"); assert_eq!(prepared.to, "Maya "); assert_eq!(prepared.cc, "Jon "); source.to = vec![address("Alex", "alex+launch@example.com")]; let prepared = prepare_message_compose(&source, "reply", "", english).unwrap(); assert_eq!(prepared.sender_email, "alex@example.com"); } #[test] fn forward_uses_the_alias_that_received_the_message() { let mut source = source(); source.to = vec![address("Alex at work", "work@example.net")]; let prepared = prepare_message_compose(&source, "forward", "", english).unwrap(); assert_eq!(prepared.sender_email, "work@example.net"); } #[test] fn forward_starts_unaddressed_and_does_not_join_the_original_thread() { let prepared = prepare_message_compose(&source(), "forward", "Rendered body", english).unwrap(); assert!(prepared.to.is_empty()); assert!(prepared.cc.is_empty()); assert_eq!(prepared.subject, "Fwd: Launch plan"); let quote = prepared.quote.unwrap(); assert!(quote.forward); assert!( quote .plain .starts_with("---------- Forwarded message ----------") ); assert!(quote.plain.ends_with("Rendered body")); assert_eq!(prepared.intent.mode, "forward"); assert_eq!(prepared.intent.in_reply_to_message_id, None); } #[test] fn a_message_edited_as_new_is_the_message_as_it_was_and_no_reply() { let prepared = prepare_message_compose(&source(), "edit_as_new", "Rendered body\n\n", english) .unwrap(); assert_eq!(prepared.account_id, 7); assert_eq!( prepared.to, "Alex , Jon ", "the same recipients, the account's own address too" ); assert_eq!( prepared.cc, "Jon Duplicate , Lena " ); assert_eq!(prepared.subject, "Launch plan", "no prefix"); assert_eq!(prepared.body, "Rendered body"); assert_eq!(prepared.intent, ComposeIntent::default()); // Received: sent from the address it reached. assert_eq!(prepared.sender_email, "alex@example.com"); // Sent from an alias: sent from it again. let mut sent = source(); sent.from = address("Alex at work", "work@example.net"); let prepared = prepare_message_compose(&sent, "edit_as_new", "", english).unwrap(); assert_eq!(prepared.sender_email, "work@example.net"); assert_eq!(prepared.body, "First line\nSecond line"); assert!(prepared.saved.is_none(), "no HTML to keep"); // Its HTML comes with the plain text it was sent with, which the // HTML has to match for its formatting to be kept. sent.html_body = Some("
First line
Second line
".to_owned()); let prepared = prepare_message_compose(&sent, "edit_as_new", "Rendered body", english).unwrap(); let saved = prepared.saved.unwrap(); assert_eq!(saved.html, "
First line
Second line
"); assert_eq!(saved.text, sent.text_body); assert_eq!(prepared.body, "Rendered body"); } #[test] fn quotation_headers_use_the_interface_wording_and_date_format() { let sent_at = date_format::current().date_time( DateStyle::WeekdayMedium, Local .timestamp_millis_opt(source().date) .unwrap() .naive_local(), ); let reply = prepare_message_compose(&source(), "reply", "", english).unwrap(); assert_eq!( reply.quote.unwrap().plain, format!("On {sent_at}, Maya wrote:\n> First line\n> Second line") ); let forward = prepare_message_compose(&source(), "forward", "", english).unwrap(); assert_eq!( forward.quote.unwrap().plain, format!( "---------- Forwarded message ----------\nFrom: Maya \n\ Date: {sent_at}\nSubject: Launch plan\n\ To: Alex , Jon \n\nFirst line\nSecond line" ) ); let shouted = |message: UiMessage| message.english().to_uppercase(); let forward = prepare_message_compose(&source(), "forward", "", shouted).unwrap(); assert!(forward.quote.unwrap().plain.starts_with( "---------- FORWARDED MESSAGE ----------\nFROM: MAYA \nDATE: " )); let mut undated = source(); undated.date = i64::MAX; let reply = prepare_message_compose(&undated, "reply", "", english).unwrap(); assert!( reply .quote .unwrap() .plain .starts_with("Maya wrote:\n> First line") ); // An HTML original is quoted as HTML. let mut html = source(); html.html_body = Some("

First line

".to_owned()); let reply = prepare_message_compose(&html, "reply", "", english).unwrap(); assert!( reply .quote .unwrap() .html .contains("

First line

") ); } #[test] fn reply_subject_does_not_stack_prefixes() { let mut reply = source(); reply.subject = "RE: Launch plan".to_owned(); assert_eq!( prepare_message_compose(&reply, "reply", "", english) .unwrap() .subject, "RE: Launch plan" ); reply.subject = "FW: Launch plan".to_owned(); assert_eq!( prepare_message_compose(&reply, "forward", "", english) .unwrap() .subject, "FW: Launch plan" ); } #[test] fn recipient_query_uses_text_after_last_separator_including_newlines() { assert_eq!(compose_recipient_query("ada@example.com, gr"), "gr"); assert_eq!(compose_recipient_query("ada@example.com; gr"), "gr"); assert_eq!(compose_recipient_query("ada@example.com\ngr"), "gr"); } #[test] fn complete_recipient_keeps_newline_separated_prefix_usable() { let completed = complete_compose_recipient( "ada@example.com\ngr", &address("Grace", "grace@example.com"), ); assert_eq!( completed, "ada@example.com, Grace , " ); } }