//! Reply/forward compose-prefill construction. //! //! Building a reply or forward is domain logic, recipient assembly with //! own-address exclusion, `Re:`/`Fwd:` subject prefixing, and body quoting. //! It lives here in `core` (not the JS frontend) so the rules are uniform, //! unit-tested, and driven by the authoritative account list rather than a //! browser-side cache. /// The text parts of a compose prefill. Threading/account IDs are plumbed by /// the command that calls these functions. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ComposePrefill { pub to: String, pub subject: String, pub body: String, } /// Extract the bare address from `Name `, or return the trimmed input /// when there is no angle-bracket form. pub fn extract_email_address(addr: &str) -> &str { let trimmed = addr.trim(); if let Some(open) = trimmed.find('<') && let Some(rel_close) = trimmed[open + 1..].find('>') { return trimmed[open + 1..open + 1 + rel_close].trim(); } trimmed } /// Prefix `Re:` unless the subject already begins with it (case-insensitive), /// matching the old JS `/^Re:/i` guard. pub fn reply_subject(subject: &str) -> String { prefix_once(subject, "Re:") } /// Prefix `Fwd:` unless the subject already begins with it (case-insensitive). pub fn forward_subject(subject: &str) -> String { prefix_once(subject, "Fwd:") } fn prefix_once(subject: &str, prefix: &str) -> String { let already = subject.len() >= prefix.len() && subject.is_char_boundary(prefix.len()) && subject[..prefix.len()].eq_ignore_ascii_case(prefix); if already { subject.to_string() } else { format!("{prefix} {subject}") } } /// Build the `To` line for a reply. /// /// - Plain reply: the original sender's address. /// - Reply-all: sender + every original `To` recipient, in order, de-duplicated, /// with the user's own addresses removed. `own_addresses` may be in any case. pub fn reply_recipients(from: &str, to: &str, own_addresses: &[String], reply_all: bool) -> String { if !reply_all { let sender = extract_email_address(from); return if sender.is_empty() { from.trim().to_string() } else { sender.to_string() }; } let own: std::collections::HashSet = own_addresses.iter().map(|a| a.to_lowercase()).collect(); let mut result: Vec = Vec::new(); let push_unique = |addr: &str, result: &mut Vec| { if addr.is_empty() || own.contains(&addr.to_lowercase()) { return; } if !result.iter().any(|r| r == addr) { result.push(addr.to_string()); } }; push_unique(extract_email_address(from), &mut result); for part in to.split(',') { push_unique(extract_email_address(part.trim()), &mut result); } result.join(", ") } /// Build the quoted body for a reply: an attribution line followed by the /// original body with each line prefixed by `> `. pub fn quoted_reply_body(from: &str, date: &str, body: &str) -> String { let quoted: String = body .split('\n') .map(|l| format!("> {l}")) .collect::>() .join("\n"); format!("\n\nOn {date}, {from} wrote:\n>\n{quoted}") } /// Build the body for a forwarded message: a header block followed by the /// original body verbatim. pub fn forward_body(from: &str, date: &str, subject: &str, to: &str, body: &str) -> String { format!( "\n\n---------- Forwarded message ----------\n\ From: {from}\n\ Date: {date}\n\ Subject: {subject}\n\ To: {to}\n\n\ {body}" ) } #[cfg(test)] mod tests { use super::*; #[test] fn extract_handles_angle_brackets_and_plain() { assert_eq!( extract_email_address("Max "), "max@example.com" ); assert_eq!( extract_email_address(" plain@example.com "), "plain@example.com" ); assert_eq!( extract_email_address("Name < spaced@example.com >"), "spaced@example.com" ); assert_eq!(extract_email_address(""), ""); } #[test] fn subject_prefix_is_idempotent_and_case_insensitive() { assert_eq!(reply_subject("Hello"), "Re: Hello"); assert_eq!(reply_subject("Re: Hello"), "Re: Hello"); assert_eq!(reply_subject("re: hello"), "re: hello"); assert_eq!(forward_subject("Hello"), "Fwd: Hello"); assert_eq!(forward_subject("Fwd: Hello"), "Fwd: Hello"); assert_eq!(forward_subject(""), "Fwd: "); } #[test] fn plain_reply_targets_sender_only() { let to = reply_recipients("Alice ", "me@x.com, bob@x.com", &[], false); assert_eq!(to, "alice@x.com"); } #[test] fn reply_all_includes_recipients_minus_self_deduped() { let own = vec!["me@x.com".to_string()]; let to = reply_recipients( "Alice ", "me@x.com, Bob , alice@x.com", &own, true, ); // sender first, then To recipients; own address dropped; alice not duplicated. assert_eq!(to, "alice@x.com, bob@x.com"); } #[test] fn reply_all_excludes_self_case_insensitively() { let own = vec!["Me@X.com".to_string()]; let to = reply_recipients("boss@x.com", "ME@x.com, peer@x.com", &own, true); assert_eq!(to, "boss@x.com, peer@x.com"); } #[test] fn quoted_reply_body_prefixes_each_line() { let body = quoted_reply_body("Alice ", "Mar 1", "line1\nline2"); assert_eq!( body, "\n\nOn Mar 1, Alice wrote:\n>\n> line1\n> line2" ); } #[test] fn forward_body_has_header_block() { let body = forward_body("a@x.com", "Mar 1", "Hi", "b@x.com", "original"); assert!(body.contains("---------- Forwarded message ----------")); assert!(body.contains("From: a@x.com")); assert!(body.contains("Subject: Hi")); assert!(body.ends_with("original")); } }