| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
#[derive(Debug, Clone, PartialEq, Eq)] |
| 12 |
pub struct ComposePrefill { |
| 13 |
pub to: String, |
| 14 |
pub subject: String, |
| 15 |
pub body: String, |
| 16 |
} |
| 17 |
|
| 18 |
|
| 19 |
|
| 20 |
pub fn extract_email_address(addr: &str) -> &str { |
| 21 |
let trimmed = addr.trim(); |
| 22 |
if let Some(open) = trimmed.find('<') |
| 23 |
&& let Some(rel_close) = trimmed[open + 1..].find('>') |
| 24 |
{ |
| 25 |
return trimmed[open + 1..open + 1 + rel_close].trim(); |
| 26 |
} |
| 27 |
trimmed |
| 28 |
} |
| 29 |
|
| 30 |
|
| 31 |
|
| 32 |
pub fn reply_subject(subject: &str) -> String { |
| 33 |
prefix_once(subject, "Re:") |
| 34 |
} |
| 35 |
|
| 36 |
|
| 37 |
pub fn forward_subject(subject: &str) -> String { |
| 38 |
prefix_once(subject, "Fwd:") |
| 39 |
} |
| 40 |
|
| 41 |
fn prefix_once(subject: &str, prefix: &str) -> String { |
| 42 |
let already = subject.len() >= prefix.len() |
| 43 |
&& subject.is_char_boundary(prefix.len()) |
| 44 |
&& subject[..prefix.len()].eq_ignore_ascii_case(prefix); |
| 45 |
if already { |
| 46 |
subject.to_string() |
| 47 |
} else { |
| 48 |
format!("{prefix} {subject}") |
| 49 |
} |
| 50 |
} |
| 51 |
|
| 52 |
|
| 53 |
|
| 54 |
|
| 55 |
|
| 56 |
|
| 57 |
pub fn reply_recipients(from: &str, to: &str, own_addresses: &[String], reply_all: bool) -> String { |
| 58 |
if !reply_all { |
| 59 |
let sender = extract_email_address(from); |
| 60 |
return if sender.is_empty() { |
| 61 |
from.trim().to_string() |
| 62 |
} else { |
| 63 |
sender.to_string() |
| 64 |
}; |
| 65 |
} |
| 66 |
|
| 67 |
let own: std::collections::HashSet<String> = |
| 68 |
own_addresses.iter().map(|a| a.to_lowercase()).collect(); |
| 69 |
let mut result: Vec<String> = Vec::new(); |
| 70 |
|
| 71 |
let push_unique = |addr: &str, result: &mut Vec<String>| { |
| 72 |
if addr.is_empty() || own.contains(&addr.to_lowercase()) { |
| 73 |
return; |
| 74 |
} |
| 75 |
if !result.iter().any(|r| r == addr) { |
| 76 |
result.push(addr.to_string()); |
| 77 |
} |
| 78 |
}; |
| 79 |
|
| 80 |
push_unique(extract_email_address(from), &mut result); |
| 81 |
for part in to.split(',') { |
| 82 |
push_unique(extract_email_address(part.trim()), &mut result); |
| 83 |
} |
| 84 |
result.join(", ") |
| 85 |
} |
| 86 |
|
| 87 |
|
| 88 |
|
| 89 |
pub fn quoted_reply_body(from: &str, date: &str, body: &str) -> String { |
| 90 |
let quoted: String = body |
| 91 |
.split('\n') |
| 92 |
.map(|l| format!("> {l}")) |
| 93 |
.collect::<Vec<_>>() |
| 94 |
.join("\n"); |
| 95 |
format!("\n\nOn {date}, {from} wrote:\n>\n{quoted}") |
| 96 |
} |
| 97 |
|
| 98 |
|
| 99 |
|
| 100 |
pub fn forward_body(from: &str, date: &str, subject: &str, to: &str, body: &str) -> String { |
| 101 |
format!( |
| 102 |
"\n\n---------- Forwarded message ----------\n\ |
| 103 |
From: {from}\n\ |
| 104 |
Date: {date}\n\ |
| 105 |
Subject: {subject}\n\ |
| 106 |
To: {to}\n\n\ |
| 107 |
{body}" |
| 108 |
) |
| 109 |
} |
| 110 |
|
| 111 |
#[cfg(test)] |
| 112 |
mod tests { |
| 113 |
use super::*; |
| 114 |
|
| 115 |
#[test] |
| 116 |
fn extract_handles_angle_brackets_and_plain() { |
| 117 |
assert_eq!( |
| 118 |
extract_email_address("Max <max@example.com>"), |
| 119 |
"max@example.com" |
| 120 |
); |
| 121 |
assert_eq!( |
| 122 |
extract_email_address(" plain@example.com "), |
| 123 |
"plain@example.com" |
| 124 |
); |
| 125 |
assert_eq!( |
| 126 |
extract_email_address("Name < spaced@example.com >"), |
| 127 |
"spaced@example.com" |
| 128 |
); |
| 129 |
assert_eq!(extract_email_address(""), ""); |
| 130 |
} |
| 131 |
|
| 132 |
#[test] |
| 133 |
fn subject_prefix_is_idempotent_and_case_insensitive() { |
| 134 |
assert_eq!(reply_subject("Hello"), "Re: Hello"); |
| 135 |
assert_eq!(reply_subject("Re: Hello"), "Re: Hello"); |
| 136 |
assert_eq!(reply_subject("re: hello"), "re: hello"); |
| 137 |
assert_eq!(forward_subject("Hello"), "Fwd: Hello"); |
| 138 |
assert_eq!(forward_subject("Fwd: Hello"), "Fwd: Hello"); |
| 139 |
assert_eq!(forward_subject(""), "Fwd: "); |
| 140 |
} |
| 141 |
|
| 142 |
#[test] |
| 143 |
fn plain_reply_targets_sender_only() { |
| 144 |
let to = reply_recipients("Alice <alice@x.com>", "me@x.com, bob@x.com", &[], false); |
| 145 |
assert_eq!(to, "alice@x.com"); |
| 146 |
} |
| 147 |
|
| 148 |
#[test] |
| 149 |
fn reply_all_includes_recipients_minus_self_deduped() { |
| 150 |
let own = vec!["me@x.com".to_string()]; |
| 151 |
let to = reply_recipients( |
| 152 |
"Alice <alice@x.com>", |
| 153 |
"me@x.com, Bob <bob@x.com>, alice@x.com", |
| 154 |
&own, |
| 155 |
true, |
| 156 |
); |
| 157 |
|
| 158 |
assert_eq!(to, "alice@x.com, bob@x.com"); |
| 159 |
} |
| 160 |
|
| 161 |
#[test] |
| 162 |
fn reply_all_excludes_self_case_insensitively() { |
| 163 |
let own = vec!["Me@X.com".to_string()]; |
| 164 |
let to = reply_recipients("boss@x.com", "ME@x.com, peer@x.com", &own, true); |
| 165 |
assert_eq!(to, "boss@x.com, peer@x.com"); |
| 166 |
} |
| 167 |
|
| 168 |
#[test] |
| 169 |
fn quoted_reply_body_prefixes_each_line() { |
| 170 |
let body = quoted_reply_body("Alice <a@x.com>", "Mar 1", "line1\nline2"); |
| 171 |
assert_eq!( |
| 172 |
body, |
| 173 |
"\n\nOn Mar 1, Alice <a@x.com> wrote:\n>\n> line1\n> line2" |
| 174 |
); |
| 175 |
} |
| 176 |
|
| 177 |
#[test] |
| 178 |
fn forward_body_has_header_block() { |
| 179 |
let body = forward_body("a@x.com", "Mar 1", "Hi", "b@x.com", "original"); |
| 180 |
assert!(body.contains("---------- Forwarded message ----------")); |
| 181 |
assert!(body.contains("From: a@x.com")); |
| 182 |
assert!(body.contains("Subject: Hi")); |
| 183 |
assert!(body.ends_with("original")); |
| 184 |
} |
| 185 |
} |
| 186 |
|