Skip to main content

max / goingson

6.1 KB · 186 lines History Blame Raw
1 //! Reply/forward compose-prefill construction.
2 //!
3 //! Building a reply or forward is domain logic, recipient assembly with
4 //! own-address exclusion, `Re:`/`Fwd:` subject prefixing, and body quoting.
5 //! It lives here in `core` (not the JS frontend) so the rules are uniform,
6 //! unit-tested, and driven by the authoritative account list rather than a
7 //! browser-side cache.
8
9 /// The text parts of a compose prefill. Threading/account IDs are plumbed by
10 /// the command that calls these functions.
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 /// Extract the bare address from `Name <addr@host>`, or return the trimmed input
19 /// when there is no angle-bracket form.
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 /// Prefix `Re:` unless the subject already begins with it (case-insensitive),
31 /// matching the old JS `/^Re:/i` guard.
32 pub fn reply_subject(subject: &str) -> String {
33 prefix_once(subject, "Re:")
34 }
35
36 /// Prefix `Fwd:` unless the subject already begins with it (case-insensitive).
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 /// Build the `To` line for a reply.
53 ///
54 /// - Plain reply: the original sender's address.
55 /// - Reply-all: sender + every original `To` recipient, in order, de-duplicated,
56 /// with the user's own addresses removed. `own_addresses` may be in any case.
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 /// Build the quoted body for a reply: an attribution line followed by the
88 /// original body with each line prefixed by `> `.
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 /// Build the body for a forwarded message: a header block followed by the
99 /// original body verbatim.
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 // sender first, then To recipients; own address dropped; alice not duplicated.
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