Skip to main content

max / goingson

Add Reply and Reply-All to email Two distinct actions: Reply (sender only, primary button) and Reply-All (sender + all recipients minus self, secondary button). Compose window prefills To, Subject (Re: prefix), quoted body, and auto-selects the receiving account. Outbound replies set In-Reply-To and References headers for correct threading. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-03 01:11 UTC
Commit: 4d5bca214cdab061a0253f916c23a11f4c4cca0a
Parent: ba94373
7 files changed, +319 insertions, -53 deletions
@@ -92,45 +92,13 @@ v0.3.0. Audit grade A. ~762 tests.
92 92
93 93 ---
94 94
95 - ## Code Fuzz Fixes (2026-05-02)
96 -
97 - Found by adversarial code review. Ordered by priority.
98 -
99 - ### Remaining
100 -
101 - ### MINOR
102 - - [ ] Negative duration inverts overlap detection in day planning (day_planning.rs:47)
103 - - [ ] End-of-month recurrence heuristic promotes Feb 28 to "last day" (recurrence.rs:23)
104 - - [ ] `DefaultHasher` for synthetic email message IDs unstable across Rust versions (email_sync.rs:64)
105 - - [ ] `+0d` relative date rejected, check is `num < 1` not `num < 0` (parser.rs:197)
106 - - [ ] `create_initial_snapshot` not idempotent, crash duplicates changelog (sync_service/mod.rs:144)
107 - - [ ] `write_json` export not atomic, no tmp+rename (export/backup.rs:136)
108 - - [ ] vCard import partial failure leaves orphaned contact without dedup key (commands/import_external.rs:169)
109 - - [ ] iCal events without UID bypass dedup, re-import creates duplicates (commands/import_external.rs:287)
110 - - [ ] CSV sanitization misses `\t`/`\r`/`\n` formula injection prefixes (export/csv.rs:96)
111 - - [ ] Same-name attachments overwrite each other in temp dir (commands/attachment.rs:184)
112 - - [ ] Notification cache clear at 1000 entries re-enables all notifications (notifications.rs:76)
113 - - [ ] Unparseable email dates fall back to `Utc::now()`, old spam sorts above recent mail (imap_client.rs:308)
114 -
115 - ### NOTE (won't fix, documented)
116 - - OAuth callback server accepts unauthenticated local connections for 5 min
117 - - Token expiry computed after HTTP round-trip, not before
118 - - `calculate_urgency` calls `Utc::now()` internally (impure, flaky tests)
119 - - `+Nm` relative months use 30-day approximation vs real month math in recurrence
120 - - Push-then-mark not atomic, depends on server idempotency
121 - - Malformed changelog entries silently dropped
122 - - `from_str_or_default` silently accepts enum typos
123 - - `Ordering::Relaxed` on shutdown flag, technically incorrect on ARM
124 -
125 - ---
126 -
127 95 ## Email Compose (pre-beta)
128 96
129 97 ### Done
130 98 - [x] HTML email body conversion to readable markdown via pter (replaces hand-rolled strip_html)
99 + - [x] Reply / Reply-All — two distinct buttons, In-Reply-To/References headers, quoted body, thread joining
131 100
132 101 ### Remaining
133 - - [ ] Reply / Reply-All — compose with quoted text, In-Reply-To/References headers on outbound
134 102 - [ ] Forward — prefill compose with forwarded content
135 103 - [ ] CC / BCC fields — compose currently only has To
136 104 - [ ] Multiple recipients — single to_address field needs comma or chip input
@@ -128,6 +128,7 @@
128 128 <body>
129 129 <div class="compose-toolbar">
130 130 <button class="btn btn-primary" id="send-btn" onclick="sendEmail()">Send</button>
131 + <span id="reply-indicator" style="display:none; color: var(--text-secondary); font-size: 0.8125rem; align-self: center;"></span>
131 132 <button class="btn btn-secondary" onclick="saveDraft()">Save Draft</button>
132 133 <div class="toolbar-spacer"></div>
133 134 <button class="btn btn-secondary" onclick="discardAndClose()">Discard</button>
@@ -159,6 +160,26 @@
159 160 let accounts = [];
160 161 let tauriInvoke = null;
161 162
163 + // Reply context from URL params (set when opening reply/reply-all)
164 + const replyContext = {
165 + inReplyTo: null,
166 + references: null,
167 + threadId: null,
168 + };
169 +
170 + function getUrlParams() {
171 + const params = new URLSearchParams(window.location.search);
172 + return {
173 + to: params.get('to') || '',
174 + subject: params.get('subject') || '',
175 + body: params.get('body') || '',
176 + inReplyTo: params.get('inReplyTo') || null,
177 + references: params.get('references') || null,
178 + threadId: params.get('threadId') || null,
179 + accountId: params.get('accountId') || null,
180 + };
181 + }
182 +
162 183 async function initTauri() {
163 184 if (window.__TAURI__) {
164 185 tauriInvoke = window.__TAURI__.core.invoke;
@@ -223,7 +244,8 @@
223 244 }
224 245
225 246 document.body.classList.add('compose-loading');
226 - setStatus('Sending...');
247 + const isReply = !!replyContext.inReplyTo;
248 + setStatus(isReply ? 'Sending reply...' : 'Sending...');
227 249
228 250 try {
229 251 await invoke('send_email', {
@@ -232,11 +254,14 @@
232 254 toAddress: toAddress,
233 255 subject: subject,
234 256 body: body,
235 - projectId: null
257 + projectId: null,
258 + inReplyTo: replyContext.inReplyTo,
259 + references: replyContext.references,
260 + threadId: replyContext.threadId,
236 261 }
237 262 });
238 263
239 - setStatus('Email sent!', 'success');
264 + setStatus(isReply ? 'Reply sent!' : 'Email sent!', 'success');
240 265
241 266 // Close window after short delay
242 267 setTimeout(() => {
@@ -308,7 +333,48 @@
308 333 document.addEventListener('DOMContentLoaded', async () => {
309 334 await initTauri();
310 335 await loadAccounts();
311 - document.getElementById('to-address').focus();
336 +
337 + // Apply reply context from URL params
338 + const params = getUrlParams();
339 + if (params.to) {
340 + document.getElementById('to-address').value = params.to;
341 + }
342 + if (params.subject) {
343 + document.getElementById('subject').value = params.subject;
344 + }
345 + if (params.body) {
346 + document.getElementById('body').value = params.body;
347 + }
348 + if (params.inReplyTo) {
349 + replyContext.inReplyTo = params.inReplyTo;
350 + replyContext.references = params.references;
351 + replyContext.threadId = params.threadId;
352 + }
353 + // Auto-select the From account that received the original email
354 + if (params.accountId) {
355 + const select = document.getElementById('from-account');
356 + if (select.querySelector(`option[value="${params.accountId}"]`)) {
357 + select.value = params.accountId;
358 + }
359 + }
360 +
361 + // Update UI for reply mode
362 + if (params.inReplyTo) {
363 + document.getElementById('send-btn').textContent = 'Send Reply';
364 + const indicator = document.getElementById('reply-indicator');
365 + indicator.style.display = 'inline';
366 + indicator.textContent = 'Replying to thread';
367 + }
368 +
369 + // Focus: body for replies (to/subject already filled), to for new compose
370 + if (params.inReplyTo) {
371 + document.getElementById('body').focus();
372 + // Place cursor at the start (before quoted text)
373 + const bodyEl = document.getElementById('body');
374 + bodyEl.setSelectionRange(0, 0);
375 + } else {
376 + document.getElementById('to-address').focus();
377 + }
312 378 });
313 379 </script>
314 380 </body>
@@ -291,7 +291,7 @@ const api = {
291 291 // Window — Tauri window management
292 292 window: {
293 293 setTitle: (title) => invoke('set_window_title', { title }),
294 - openCompose: () => invoke('open_compose_window'), // Separate compose window
294 + openCompose: (context) => invoke('open_compose_window', { context: context || null }), // Separate compose window, optional reply context
295 295 openEmailInBrowser: (id) => invoke('open_email_in_browser', { id }), // Render HTML email to temp file → open
296 296 },
297 297 };
@@ -304,6 +304,8 @@
304 304 ${threadContent}
305 305 </div>
306 306 <div class="form-actions" style="border-top: 1px solid var(--border-color); padding-top: 1rem; margin-top: auto;">
307 + <button class="btn btn-primary" onclick="GoingsOn.emails.reply('${escAttr(latestEmail.id)}')">Reply</button>
308 + <button class="btn btn-secondary" onclick="GoingsOn.emails.replyAll('${escAttr(latestEmail.id)}')">Reply All</button>
307 309 <button class="btn btn-secondary" style="color: var(--accent-red);" onclick="GoingsOn.emails.delete('${escAttr(latestEmail.id)}')">Delete</button>
308 310 ${archiveBtn}
309 311 ${snoozeBtn}
@@ -545,6 +547,95 @@
545 547 }
546 548
547 549 /**
550 + * Open compose window for a reply.
551 + * @param {string} emailId - Email to reply to
552 + * @param {boolean} replyAll - If true, include all recipients
553 + */
554 + async function openReply(emailId, replyAll) {
555 + try {
556 + const email = await GoingsOn.api.emails.get(emailId);
557 + if (!email) return;
558 +
559 + // Determine the From account (match the account that received this email)
560 + const accountId = email.emailAccountId || '';
561 +
562 + // Determine the To address
563 + let to;
564 + if (replyAll) {
565 + // Reply-All: sender + all recipients, minus our own address
566 + const accounts = GoingsOn.getEmailAccountsCache();
567 + const ownAddresses = new Set(accounts.map(a => a.email.toLowerCase()));
568 + const allAddresses = [];
569 +
570 + // Add original sender
571 + const senderEmail = extractEmail(email.from);
572 + if (senderEmail && !ownAddresses.has(senderEmail.toLowerCase())) {
573 + allAddresses.push(senderEmail);
574 + }
575 +
576 + // Add original To recipients
577 + if (email.to) {
578 + email.to.split(',').map(a => extractEmail(a.trim())).forEach(addr => {
579 + if (addr && !ownAddresses.has(addr.toLowerCase()) && !allAddresses.includes(addr)) {
580 + allAddresses.push(addr);
581 + }
582 + });
583 + }
584 +
585 + to = allAddresses.join(', ');
586 + } else {
587 + // Reply: just the sender
588 + to = extractEmail(email.from) || email.from;
589 + }
590 +
591 + // Build subject with Re: prefix (don't double-prefix)
592 + let subject = email.subject || '';
593 + if (!subject.match(/^Re:/i)) {
594 + subject = 'Re: ' + subject;
595 + }
596 +
597 + // Build quoted body
598 + const date = new Date(email.receivedAt).toLocaleString();
599 + const quotedLines = (email.body || '').split('\n').map(l => '> ' + l).join('\n');
600 + const body = '\n\nOn ' + date + ', ' + email.from + ' wrote:\n' + quotedLines;
601 +
602 + // Build References header chain
603 + let references = '';
604 + if (email.messageId) {
605 + references = email.messageId;
606 + }
607 +
608 + // Open compose with reply context
609 + if (GoingsOn.touch?.isTouchDevice) {
610 + // Mobile fallback — fill compose modal
611 + openComposeModal();
612 + // TODO: prefill modal fields for reply
613 + } else {
614 + await GoingsOn.api.window.openCompose({
615 + to,
616 + subject,
617 + body,
618 + inReplyTo: email.messageId || null,
619 + references: references || null,
620 + threadId: email.threadId || null,
621 + accountId,
622 + });
623 + }
624 + } catch (err) {
625 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open reply'), 'error');
626 + }
627 + }
628 +
629 + /**
630 + * Extract bare email address from "Name <email>" format.
631 + */
632 + function extractEmail(addr) {
633 + if (!addr) return '';
634 + const match = addr.match(/<([^>]+)>/);
635 + return match ? match[1] : addr.trim();
636 + }
637 +
638 + /**
548 639 * Render email HTML to a temp file and open in the system browser.
549 640 * @param {string} emailId - Email ID to open
550 641 */
@@ -599,6 +690,8 @@
599 690 createEventFromEmail,
600 691 createContactFromSender,
601 692 openInBrowser,
693 + reply: (id) => openReply(id, false),
694 + replyAll: (id) => openReply(id, true),
602 695 // Pagination
603 696 goToPage,
604 697 toggleSelection,
@@ -93,6 +93,12 @@ pub struct SendEmailInput {
93 93 pub subject: String,
94 94 pub body: String,
95 95 pub project_id: Option<ProjectId>,
96 + /// Message-ID of the email being replied to (sets In-Reply-To header).
97 + pub in_reply_to: Option<String>,
98 + /// Full References header chain for threading.
99 + pub references: Option<String>,
100 + /// Thread ID to join (from the original email's thread).
101 + pub thread_id: Option<String>,
96 102 }
97 103
98 104 #[derive(Debug, Serialize)]
@@ -409,6 +415,8 @@ pub async fn send_email(state: State<'_, Arc<AppState>>, input: SendEmailInput)
409 415 .await?
410 416 .or_not_found("emailAccount", input.account_id)?;
411 417
418 + let is_reply = input.in_reply_to.is_some();
419 +
412 420 let message_id = if uses_jmap(&account) {
413 421 return Err(ApiError::bad_request("JMAP email sending not yet implemented - use IMAP account"));
414 422 } else if uses_oauth_imap(&account) {
@@ -420,19 +428,48 @@ pub async fn send_email(state: State<'_, Arc<AppState>>, input: SendEmailInput)
420 428 &access_token,
421 429 true,
422 430 );
423 - smtp_client
424 - .send_email(&input.to_address, &input.subject, &input.body)
425 - .await
426 - .map_api_err("Failed to send email", ApiError::external_service)?
431 + if is_reply {
432 + smtp_client
433 + .send_reply(
434 + &input.to_address,
435 + &input.subject,
436 + &input.body,
437 + input.in_reply_to.as_deref().unwrap_or(""),
438 + input.references.as_deref().unwrap_or(""),
439 + )
440 + .await
441 + .map_api_err("Failed to send reply", ApiError::external_service)?
442 + } else {
443 + smtp_client
444 + .send_email(&input.to_address, &input.subject, &input.body)
445 + .await
446 + .map_api_err("Failed to send email", ApiError::external_service)?
447 + }
427 448 } else {
428 449 let password = get_account_password(&account)?;
429 450 let smtp_client = SmtpClient::with_password(&account, &password);
430 - smtp_client
431 - .send_email(&input.to_address, &input.subject, &input.body)
432 - .await
433 - .map_api_err("Failed to send email", ApiError::external_service)?
451 + if is_reply {
452 + smtp_client
453 + .send_reply(
454 + &input.to_address,
455 + &input.subject,
456 + &input.body,
457 + input.in_reply_to.as_deref().unwrap_or(""),
458 + input.references.as_deref().unwrap_or(""),
459 + )
460 + .await
461 + .map_api_err("Failed to send reply", ApiError::external_service)?
462 + } else {
463 + smtp_client
464 + .send_email(&input.to_address, &input.subject, &input.body)
465 + .await
466 + .map_api_err("Failed to send email", ApiError::external_service)?
467 + }
434 468 };
435 469
470 + // For replies, join the existing thread. For new emails, start a new thread.
471 + let thread_id = input.thread_id.unwrap_or_else(|| message_id.clone());
472 +
436 473 let new_email = NewEmailWithTracking {
437 474 project_id: input.project_id,
438 475 from_address: account.email_address.clone(),
@@ -444,8 +481,8 @@ pub async fn send_email(state: State<'_, Arc<AppState>>, input: SendEmailInput)
444 481 is_archived: false,
445 482 received_at: Some(Utc::now()),
446 483 message_id: Some(message_id.clone()),
447 - in_reply_to: None,
448 - thread_id: Some(message_id.clone()),
484 + in_reply_to: input.in_reply_to,
485 + thread_id: Some(thread_id),
449 486 imap_uid: None,
450 487 source_folder: Some("Sent".to_string()),
451 488 email_account_id: Some(input.account_id),
@@ -7,12 +7,42 @@ use tracing::instrument;
7 7
8 8 use super::{ApiError, ResultApiError};
9 9
10 + /// Percent-encode a string for use in URL query parameters.
11 + fn url_encode(s: &str) -> String {
12 + let mut encoded = String::with_capacity(s.len() * 2);
13 + for byte in s.bytes() {
14 + match byte {
15 + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
16 + encoded.push(byte as char);
17 + }
18 + _ => {
19 + encoded.push_str(&format!("%{:02X}", byte));
20 + }
21 + }
22 + }
23 + encoded
24 + }
25 +
10 26 // ============ Commands ============
11 27
28 + /// Optional reply context passed when opening compose for a reply.
29 + #[derive(Debug, serde::Deserialize, Default)]
30 + #[serde(rename_all = "camelCase")]
31 + pub struct ComposeContext {
32 + pub to: Option<String>,
33 + pub subject: Option<String>,
34 + pub body: Option<String>,
35 + pub in_reply_to: Option<String>,
36 + pub references: Option<String>,
37 + pub thread_id: Option<String>,
38 + pub account_id: Option<String>,
39 + }
40 +
12 41 #[tauri::command]
13 42 #[instrument(skip_all)]
14 43 pub async fn open_compose_window(
15 44 #[allow(unused_variables)] app: tauri::AppHandle,
45 + #[allow(unused_variables)] context: Option<ComposeContext>,
16 46 ) -> Result<(), ApiError> {
17 47 #[cfg(not(any(target_os = "ios", target_os = "android")))]
18 48 {
@@ -20,8 +50,44 @@ pub async fn open_compose_window(
20 50
21 51 let label = format!("compose-{}", chrono::Utc::now().timestamp_millis());
22 52
23 - WebviewWindowBuilder::new(&app, &label, WebviewUrl::App("compose.html".into()))
24 - .title("Compose Email")
53 + // Build URL with reply context as query params
54 + let mut url_str = "compose.html".to_string();
55 + if let Some(ctx) = &context {
56 + let mut params = Vec::new();
57 + if let Some(to) = &ctx.to {
58 + params.push(format!("to={}", url_encode(to)));
59 + }
60 + if let Some(subject) = &ctx.subject {
61 + params.push(format!("subject={}", url_encode(subject)));
62 + }
63 + if let Some(body) = &ctx.body {
64 + params.push(format!("body={}", url_encode(body)));
65 + }
66 + if let Some(irt) = &ctx.in_reply_to {
67 + params.push(format!("inReplyTo={}", url_encode(irt)));
68 + }
69 + if let Some(refs) = &ctx.references {
70 + params.push(format!("references={}", url_encode(refs)));
71 + }
72 + if let Some(tid) = &ctx.thread_id {
73 + params.push(format!("threadId={}", url_encode(tid)));
74 + }
75 + if let Some(aid) = &ctx.account_id {
76 + params.push(format!("accountId={}", url_encode(aid)));
77 + }
78 + if !params.is_empty() {
79 + url_str = format!("compose.html?{}", params.join("&"));
80 + }
81 + }
82 +
83 + let title = if context.as_ref().and_then(|c| c.in_reply_to.as_ref()).is_some() {
84 + "Reply"
85 + } else {
86 + "Compose Email"
87 + };
88 +
89 + WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url_str.into()))
90 + .title(title)
25 91 .inner_size(700.0, 550.0)
26 92 .min_inner_size(500.0, 400.0)
27 93 .resizable(true)
@@ -124,7 +124,34 @@ impl SmtpClient {
124 124 }
125 125 }
126 126
127 - pub async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<String, String> {
127 + pub async fn send_email(
128 + &self,
129 + to: &str,
130 + subject: &str,
131 + body: &str,
132 + ) -> Result<String, String> {
133 + self.send_email_with_headers(to, subject, body, None, None).await
134 + }
135 +
136 + pub async fn send_reply(
137 + &self,
138 + to: &str,
139 + subject: &str,
140 + body: &str,
141 + in_reply_to: &str,
142 + references: &str,
143 + ) -> Result<String, String> {
144 + self.send_email_with_headers(to, subject, body, Some(in_reply_to), Some(references)).await
145 + }
146 +
147 + async fn send_email_with_headers(
148 + &self,
149 + to: &str,
150 + subject: &str,
151 + body: &str,
152 + in_reply_to: Option<&str>,
153 + references: Option<&str>,
154 + ) -> Result<String, String> {
128 155 // Generate message ID before building so sent email and local DB agree
129 156 let message_id = format!(
130 157 "<{}.{}@{}>",
@@ -133,7 +160,7 @@ impl SmtpClient {
133 160 self.server
134 161 );
135 162
136 - let email = Message::builder()
163 + let mut builder = Message::builder()
137 164 .message_id(Some(message_id.clone()))
138 165 .from(
139 166 self.from_address
@@ -141,7 +168,16 @@ impl SmtpClient {
141 168 .map_err(|e| format!("Invalid from address: {}", e))?,
142 169 )
143 170 .to(to.parse().map_err(|e| format!("Invalid to address: {}", e))?)
144 - .subject(subject)
171 + .subject(subject);
172 +
173 + if let Some(irt) = in_reply_to {
174 + builder = builder.in_reply_to(irt.to_string());
175 + }
176 + if let Some(refs) = references {
177 + builder = builder.references(refs.to_string());
178 + }
179 +
180 + let email = builder
145 181 .header(ContentType::TEXT_PLAIN)
146 182 .body(body.to_string())
147 183 .map_err(|e| format!("Failed to build email: {}", e))?;