Skip to main content

max / goingson

GO-33-5: move email reply/forward assembly from JS to Rust The "what belongs in JS" pass on emails.js: reply/forward prefill construction (recipient assembly, Re:/Fwd: prefixing, quoting) is domain logic, not view code. - New core::email_compose module: pure reply_recipients / reply_subject / forward_subject / quoted_reply_body / forward_body / extract_email_address, with 7 unit tests - New commands build_reply_prefill / build_forward_prefill; reply-all now excludes the user's own addresses using the authoritative account repo instead of the browser-side getEmailAccountsCache - openReply/openForward drop from ~120 to ~15 LOC each; dead extractEmail removed - emails.js 1593 -> 1515 LOC The pure file-split half of GO-33-5 is left for an interactive pass: the render/ CRUD handlers close over module-local runtime state (emailSelection/pagination/ scroller), so a safe split needs namespace promotion the static JS gate can't verify. 920 tests green, clippy clean, JS gate 56/56. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-06 15:49 UTC
Commit: be01d90cbb865c7beb920d58c025c9bed1ed8f40
Parent: 1237aeb
6 files changed, +290 insertions, -100 deletions
@@ -0,0 +1,174 @@
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 + }
@@ -36,6 +36,7 @@ pub mod contact;
36 36 pub mod date_parser;
37 37 pub mod date_utils;
38 38 pub mod day_planning;
39 + pub mod email_compose;
39 40 pub mod email_id;
40 41 pub mod email_sync;
41 42 pub mod error;
@@ -90,6 +91,10 @@ pub use import::{
90 91 ImportItemData, ImportOptions, ImportParseResult, ImportProjectData, ImportTaskData,
91 92 };
92 93 pub use email_id::deterministic_email_id;
94 + pub use email_compose::{
95 + forward_body, forward_subject, quoted_reply_body, reply_recipients, reply_subject,
96 + ComposePrefill,
97 + };
93 98 pub use validation::Validate;
94 99
95 100 /// A specialized `Result` type for core operations.
@@ -148,6 +148,8 @@ const api = {
148 148 listByProject: (projectId) => invoke('list_emails_for_project', { projectId }),
149 149 listUnlinked: () => invoke('list_unlinked_emails'), // Emails not linked to any project
150 150 get: (id) => invoke('get_email', { id }),
151 + buildReplyPrefill: (id, replyAll) => invoke('build_reply_prefill', { id, replyAll }), // Rust builds reply recipients/subject/quote
152 + buildForwardPrefill: (id) => invoke('build_forward_prefill', { id }), // Rust builds Fwd: subject + forwarded body
151 153 fetchFullBody: (id) => invoke('fetch_email_full_body', { id }), // Lazy-load a body truncated at sync (JMAP >100KB)
152 154 create: (input) => invoke('create_email', { input }),
153 155 send: (input) => invoke('send_email', { input }), // SMTP send + save copy to local DB
@@ -859,79 +859,25 @@
859 859 */
860 860 async function openReply(emailId, replyAll) {
861 861 try {
862 - const email = await GoingsOn.api.emails.get(emailId);
863 - if (!email) return;
864 -
865 - // Determine the From account (match the account that received this email)
866 - const accountId = email.emailAccountId || '';
867 -
868 - // Determine the To address
869 - let to;
870 - if (replyAll) {
871 - // Reply-All: sender + all recipients, minus our own address
872 - const accounts = GoingsOn.getEmailAccountsCache();
873 - const ownAddresses = new Set(accounts.map(a => a.email.toLowerCase()));
874 - const allAddresses = [];
875 -
876 - // Add original sender
877 - const senderEmail = extractEmail(email.from);
878 - if (senderEmail && !ownAddresses.has(senderEmail.toLowerCase())) {
879 - allAddresses.push(senderEmail);
880 - }
881 -
882 - // Add original To recipients
883 - if (email.to) {
884 - email.to.split(',').map(a => extractEmail(a.trim())).forEach(addr => {
885 - if (addr && !ownAddresses.has(addr.toLowerCase()) && !allAddresses.includes(addr)) {
886 - allAddresses.push(addr);
887 - }
888 - });
889 - }
890 -
891 - to = allAddresses.join(', ');
892 - } else {
893 - // Reply: just the sender
894 - to = extractEmail(email.from) || email.from;
895 - }
896 -
897 - // Build subject with Re: prefix (don't double-prefix)
898 - let subject = email.subject || '';
899 - if (!subject.match(/^Re:/i)) {
900 - subject = 'Re: ' + subject;
901 - }
902 -
903 - // Build quoted body
904 - const date = new Date(email.receivedAt).toLocaleString();
905 - const quotedLines = (email.body || '').split('\n').map(l => '> ' + l).join('\n');
906 - const body = '\n\nOn ' + date + ', ' + email.from + ' wrote:\n>\n' + quotedLines;
907 -
908 - // Build References header chain
909 - let references = '';
910 - if (email.messageId) {
911 - references = email.messageId;
912 - }
862 + // Rust builds the reply prefill: recipients (reply-all excludes our
863 + // own accounts + de-dupes), Re: subject, and quoted body.
864 + const pf = await GoingsOn.api.emails.buildReplyPrefill(emailId, !!replyAll);
865 + if (!pf) return;
866 +
867 + const prefill = {
868 + to: pf.to,
869 + subject: pf.subject,
870 + body: pf.body,
871 + inReplyTo: pf.inReplyTo || null,
872 + references: pf.references || null,
873 + threadId: pf.threadId || null,
874 + accountId: pf.accountId || '',
875 + };
913 876
914 - // Open compose with reply context
915 877 if (GoingsOn.touch?.isTouchDevice) {
916 - openComposeModal({
917 - to,
918 - subject,
919 - body,
920 - inReplyTo: email.messageId || null,
921 - references: references || null,
922 - threadId: email.threadId || null,
923 - accountId,
924 - });
878 + openComposeModal(prefill);
925 879 } else {
926 - await GoingsOn.api.window.openCompose({
927 - to,
928 - subject,
929 - body,
930 - inReplyTo: email.messageId || null,
931 - references: references || null,
932 - threadId: email.threadId || null,
933 - accountId,
934 - });
880 + await GoingsOn.api.window.openCompose(prefill);
935 881 }
936 882 } catch (err) {
937 883 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open reply'), 'error');
@@ -944,35 +890,16 @@
944 890 */
945 891 async function openForward(emailId) {
946 892 try {
947 - const email = await GoingsOn.api.emails.get(emailId);
948 - if (!email) return;
949 -
950 - const accountId = email.emailAccountId || '';
951 -
952 - // Build subject with Fwd: prefix (don't double-prefix)
953 - let subject = email.subject || '';
954 - if (!subject.match(/^Fwd:/i)) {
955 - subject = 'Fwd: ' + subject;
956 - }
893 + // Rust builds the Fwd: subject and forwarded-message body block.
894 + const pf = await GoingsOn.api.emails.buildForwardPrefill(emailId);
895 + if (!pf) return;
957 896
958 - // Build forwarded body
959 - const date = new Date(email.receivedAt).toLocaleString();
960 - const body = '\n\n---------- Forwarded message ----------\n'
961 - + 'From: ' + email.from + '\n'
962 - + 'Date: ' + date + '\n'
963 - + 'Subject: ' + (email.subject || '') + '\n'
964 - + 'To: ' + (email.to || '') + '\n\n'
965 - + (email.body || '');
897 + const prefill = { to: '', subject: pf.subject, body: pf.body, accountId: pf.accountId || '' };
966 898
967 899 if (GoingsOn.touch?.isTouchDevice) {
968 - openComposeModal({ to: '', subject, body, accountId });
900 + openComposeModal(prefill);
969 901 } else {
970 - await GoingsOn.api.window.openCompose({
971 - to: '',
972 - subject,
973 - body,
974 - accountId,
975 - });
902 + await GoingsOn.api.window.openCompose(prefill);
976 903 }
977 904 } catch (err) {
978 905 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to forward email'), 'error');
@@ -1017,11 +944,6 @@
1017 944 /**
1018 945 * Extract bare email address from "Name <email>" format.
1019 946 */
1020 - function extractEmail(addr) {
1021 - if (!addr) return '';
1022 - const match = addr.match(/<([^>]+)>/);
1023 - return match ? match[1] : addr.trim();
1024 - }
1025 947
1026 948 /**
1027 949 * Render email HTML to a temp file and open in the system browser.
@@ -344,6 +344,91 @@ pub async fn get_email(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<O
344 344 Ok(email.map(EmailResponse::from))
345 345 }
346 346
347 + /// Prefill for the compose window when replying or forwarding.
348 + ///
349 + /// All the domain logic (recipient assembly, `Re:`/`Fwd:` prefixing, quoting)
350 + /// is computed in Rust so the frontend just renders the result.
351 + #[derive(Debug, Serialize)]
352 + #[serde(rename_all = "camelCase")]
353 + pub struct ComposePrefillResponse {
354 + pub to: String,
355 + pub subject: String,
356 + pub body: String,
357 + pub in_reply_to: Option<String>,
358 + pub references: Option<String>,
359 + pub thread_id: Option<String>,
360 + /// Account to send from ("" when the source email has no account).
361 + pub account_id: String,
362 + }
363 +
364 + /// Format an email timestamp for a reply/forward attribution line, in local time.
365 + fn format_attribution_date(dt: chrono::DateTime<Utc>) -> String {
366 + dt.with_timezone(&chrono::Local)
367 + .format("%b %-d, %Y, %-I:%M %p")
368 + .to_string()
369 + }
370 +
371 + /// Builds the compose prefill for replying to an email.
372 + ///
373 + /// Reply-all assembles sender + original recipients, excluding the user's own
374 + /// account addresses (authoritative, from the account repo — not a browser
375 + /// cache) and de-duplicating.
376 + #[tauri::command]
377 + #[instrument(skip_all)]
378 + pub async fn build_reply_prefill(
379 + state: State<'_, Arc<AppState>>,
380 + id: EmailId,
381 + reply_all: bool,
382 + ) -> Result<ComposePrefillResponse, ApiError> {
383 + let email = state.emails.get_by_id(id, DESKTOP_USER_ID).await?
384 + .or_not_found("email", id)?;
385 +
386 + let own_addresses: Vec<String> = state.email_accounts.list_by_user(DESKTOP_USER_ID).await?
387 + .into_iter()
388 + .map(|a| a.email_address)
389 + .collect();
390 +
391 + let to = goingson_core::reply_recipients(&email.from, &email.to, &own_addresses, reply_all);
392 + let subject = goingson_core::reply_subject(&email.subject);
393 + let date = format_attribution_date(email.received_at);
394 + let body = goingson_core::quoted_reply_body(&email.from, &date, &email.body);
395 +
396 + Ok(ComposePrefillResponse {
397 + to,
398 + subject,
399 + body,
400 + in_reply_to: email.message_id.clone(),
401 + references: email.message_id,
402 + thread_id: email.thread_id,
403 + account_id: email.email_account_id.map(|a| a.to_string()).unwrap_or_default(),
404 + })
405 + }
406 +
407 + /// Builds the compose prefill for forwarding an email.
408 + #[tauri::command]
409 + #[instrument(skip_all)]
410 + pub async fn build_forward_prefill(
411 + state: State<'_, Arc<AppState>>,
412 + id: EmailId,
413 + ) -> Result<ComposePrefillResponse, ApiError> {
414 + let email = state.emails.get_by_id(id, DESKTOP_USER_ID).await?
415 + .or_not_found("email", id)?;
416 +
417 + let subject = goingson_core::forward_subject(&email.subject);
418 + let date = format_attribution_date(email.received_at);
419 + let body = goingson_core::forward_body(&email.from, &date, &email.subject, &email.to, &email.body);
420 +
421 + Ok(ComposePrefillResponse {
422 + to: String::new(),
423 + subject,
424 + body,
425 + in_reply_to: None,
426 + references: None,
427 + thread_id: None,
428 + account_id: email.email_account_id.map(|a| a.to_string()).unwrap_or_default(),
429 + })
430 + }
431 +
347 432 /// Fetches the full body of a JMAP email whose body was truncated at sync
348 433 /// (>100KB). Re-fetches via the provider, stores the full body, clears the
349 434 /// truncation flag, and returns the body. If the email was not truncated, the
@@ -113,6 +113,8 @@ macro_rules! all_commands {
113 113 $crate::commands::list_emails,
114 114 $crate::commands::list_emails_threaded,
115 115 $crate::commands::get_email,
116 + $crate::commands::build_reply_prefill,
117 + $crate::commands::build_forward_prefill,
116 118 $crate::commands::fetch_email_full_body,
117 119 $crate::commands::open_email_in_browser,
118 120 $crate::commands::create_email,