Skip to main content

max / goingson

6.0 KB · 175 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 {}, {} wrote:\n>\n{}", date, from, 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: {}\n\
104 Date: {}\n\
105 Subject: {}\n\
106 To: {}\n\n\
107 {}",
108 from, date, subject, to, body
109 )
110 }
111
112 #[cfg(test)]
113 mod tests {
114 use super::*;
115
116 #[test]
117 fn extract_handles_angle_brackets_and_plain() {
118 assert_eq!(extract_email_address("Max <max@example.com>"), "max@example.com");
119 assert_eq!(extract_email_address(" plain@example.com "), "plain@example.com");
120 assert_eq!(extract_email_address("Name < spaced@example.com >"), "spaced@example.com");
121 assert_eq!(extract_email_address(""), "");
122 }
123
124 #[test]
125 fn subject_prefix_is_idempotent_and_case_insensitive() {
126 assert_eq!(reply_subject("Hello"), "Re: Hello");
127 assert_eq!(reply_subject("Re: Hello"), "Re: Hello");
128 assert_eq!(reply_subject("re: hello"), "re: hello");
129 assert_eq!(forward_subject("Hello"), "Fwd: Hello");
130 assert_eq!(forward_subject("Fwd: Hello"), "Fwd: Hello");
131 assert_eq!(forward_subject(""), "Fwd: ");
132 }
133
134 #[test]
135 fn plain_reply_targets_sender_only() {
136 let to = reply_recipients("Alice <alice@x.com>", "me@x.com, bob@x.com", &[], false);
137 assert_eq!(to, "alice@x.com");
138 }
139
140 #[test]
141 fn reply_all_includes_recipients_minus_self_deduped() {
142 let own = vec!["me@x.com".to_string()];
143 let to = reply_recipients(
144 "Alice <alice@x.com>",
145 "me@x.com, Bob <bob@x.com>, alice@x.com",
146 &own,
147 true,
148 );
149 // sender first, then To recipients; own address dropped; alice not duplicated.
150 assert_eq!(to, "alice@x.com, bob@x.com");
151 }
152
153 #[test]
154 fn reply_all_excludes_self_case_insensitively() {
155 let own = vec!["Me@X.com".to_string()];
156 let to = reply_recipients("boss@x.com", "ME@x.com, peer@x.com", &own, true);
157 assert_eq!(to, "boss@x.com, peer@x.com");
158 }
159
160 #[test]
161 fn quoted_reply_body_prefixes_each_line() {
162 let body = quoted_reply_body("Alice <a@x.com>", "Mar 1", "line1\nline2");
163 assert_eq!(body, "\n\nOn Mar 1, Alice <a@x.com> wrote:\n>\n> line1\n> line2");
164 }
165
166 #[test]
167 fn forward_body_has_header_block() {
168 let body = forward_body("a@x.com", "Mar 1", "Hi", "b@x.com", "original");
169 assert!(body.contains("---------- Forwarded message ----------"));
170 assert!(body.contains("From: a@x.com"));
171 assert!(body.contains("Subject: Hi"));
172 assert!(body.ends_with("original"));
173 }
174 }
175