Skip to main content

max / goingson

Add Forward, CC/BCC, and multiple recipients Forward button in email reader with Fwd: prefix and forwarded message header block. CC/BCC fields in compose (togglable, hidden by default). All address fields accept comma-separated recipients. SMTP client refactored to SendParams struct for cleaner parameter passing. 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:14 UTC
Commit: d9fc7616c8bf815bf85655b4dca73f7262c6ab5e
Parent: 4d5bca2
6 files changed, +134 insertions, -75 deletions
@@ -97,11 +97,11 @@ v0.3.0. Audit grade A. ~762 tests.
97 97 ### Done
98 98 - [x] HTML email body conversion to readable markdown via pter (replaces hand-rolled strip_html)
99 99 - [x] Reply / Reply-All — two distinct buttons, In-Reply-To/References headers, quoted body, thread joining
100 + - [x] Forward — Fwd: prefix, forwarded message header block, From account auto-select
101 + - [x] CC / BCC fields — togglable CC/BCC rows in compose, SMTP CC/BCC headers
102 + - [x] Multiple recipients — comma-separated To/CC/BCC, per-address SMTP validation
100 103
101 104 ### Remaining
102 - - [ ] Forward — prefill compose with forwarded content
103 - - [ ] CC / BCC fields — compose currently only has To
104 - - [ ] Multiple recipients — single to_address field needs comma or chip input
105 105 - [ ] Attachment sending — can receive but not attach to outbound
106 106 - [ ] Signatures — per-account email signature
107 107 - [ ] Drafts (real) — current drafts save as regular emails with draft@local address
@@ -143,7 +143,19 @@
143 143 </div>
144 144 <div class="header-row">
145 145 <label class="header-label">To:</label>
146 - <input type="email" class="header-input" id="to-address" placeholder="recipient@example.com" required>
146 + <input type="text" class="header-input" id="to-address" placeholder="recipient@example.com (comma-separated)" required>
147 + </div>
148 + <div class="header-row" id="cc-row" style="display: none;">
149 + <label class="header-label">CC:</label>
150 + <input type="text" class="header-input" id="cc-address" placeholder="cc@example.com (comma-separated)">
151 + </div>
152 + <div class="header-row" id="bcc-row" style="display: none;">
153 + <label class="header-label">BCC:</label>
154 + <input type="text" class="header-input" id="bcc-address" placeholder="bcc@example.com (comma-separated)">
155 + </div>
156 + <div class="header-row" style="padding: 0.25rem 1rem;">
157 + <span class="header-label"></span>
158 + <button type="button" id="toggle-cc" style="background: none; border: none; color: var(--text-secondary); font-size: 0.8125rem; cursor: pointer; padding: 0;" onclick="toggleCcBcc()">Show CC/BCC</button>
147 159 </div>
148 160 <div class="header-row">
149 161 <label class="header-label">Subject:</label>
@@ -225,6 +237,8 @@
225 237 async function sendEmail() {
226 238 const accountId = document.getElementById('from-account').value;
227 239 const toAddress = document.getElementById('to-address').value.trim();
240 + const ccAddress = document.getElementById('cc-address').value.trim();
241 + const bccAddress = document.getElementById('bcc-address').value.trim();
228 242 const subject = document.getElementById('subject').value.trim();
229 243 const body = document.getElementById('body').value;
230 244
@@ -252,6 +266,8 @@
252 266 input: {
253 267 accountId: accountId,
254 268 toAddress: toAddress,
269 + ccAddress: ccAddress || null,
270 + bccAddress: bccAddress || null,
255 271 subject: subject,
256 272 body: body,
257 273 projectId: null,
@@ -313,6 +329,16 @@
313 329 window.__TAURI__.webviewWindow.getCurrentWebviewWindow().close();
314 330 }
315 331
332 + function toggleCcBcc() {
333 + const ccRow = document.getElementById('cc-row');
334 + const bccRow = document.getElementById('bcc-row');
335 + const btn = document.getElementById('toggle-cc');
336 + const visible = ccRow.style.display !== 'none';
337 + ccRow.style.display = visible ? 'none' : 'flex';
338 + bccRow.style.display = visible ? 'none' : 'flex';
339 + btn.textContent = visible ? 'Show CC/BCC' : 'Hide CC/BCC';
340 + }
341 +
316 342 function escapeHtml(text) {
317 343 const div = document.createElement('div');
318 344 div.textContent = text || '';
@@ -306,6 +306,7 @@
306 306 <div class="form-actions" style="border-top: 1px solid var(--border-color); padding-top: 1rem; margin-top: auto;">
307 307 <button class="btn btn-primary" onclick="GoingsOn.emails.reply('${escAttr(latestEmail.id)}')">Reply</button>
308 308 <button class="btn btn-secondary" onclick="GoingsOn.emails.replyAll('${escAttr(latestEmail.id)}')">Reply All</button>
309 + <button class="btn btn-secondary" onclick="GoingsOn.emails.forward('${escAttr(latestEmail.id)}')">Forward</button>
309 310 <button class="btn btn-secondary" style="color: var(--accent-red);" onclick="GoingsOn.emails.delete('${escAttr(latestEmail.id)}')">Delete</button>
310 311 ${archiveBtn}
311 312 ${snoozeBtn}
@@ -627,6 +628,47 @@
627 628 }
628 629
629 630 /**
631 + * Open compose window to forward an email.
632 + * @param {string} emailId - Email to forward
633 + */
634 + async function openForward(emailId) {
635 + try {
636 + const email = await GoingsOn.api.emails.get(emailId);
637 + if (!email) return;
638 +
639 + const accountId = email.emailAccountId || '';
640 +
641 + // Build subject with Fwd: prefix (don't double-prefix)
642 + let subject = email.subject || '';
643 + if (!subject.match(/^Fwd:/i)) {
644 + subject = 'Fwd: ' + subject;
645 + }
646 +
647 + // Build forwarded body
648 + const date = new Date(email.receivedAt).toLocaleString();
649 + const body = '\n\n---------- Forwarded message ----------\n'
650 + + 'From: ' + email.from + '\n'
651 + + 'Date: ' + date + '\n'
652 + + 'Subject: ' + (email.subject || '') + '\n'
653 + + 'To: ' + (email.to || '') + '\n\n'
654 + + (email.body || '');
655 +
656 + if (GoingsOn.touch?.isTouchDevice) {
657 + openComposeModal();
658 + } else {
659 + await GoingsOn.api.window.openCompose({
660 + to: '',
661 + subject,
662 + body,
663 + accountId,
664 + });
665 + }
666 + } catch (err) {
667 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to forward email'), 'error');
668 + }
669 + }
670 +
671 + /**
630 672 * Extract bare email address from "Name <email>" format.
631 673 */
632 674 function extractEmail(addr) {
@@ -692,6 +734,7 @@
692 734 openInBrowser,
693 735 reply: (id) => openReply(id, false),
694 736 replyAll: (id) => openReply(id, true),
737 + forward: openForward,
695 738 // Pagination
696 739 goToPage,
697 740 toggleSelection,
@@ -90,6 +90,8 @@ pub struct EmailInput {
90 90 pub struct SendEmailInput {
91 91 pub account_id: EmailAccountId,
92 92 pub to_address: String,
93 + pub cc_address: Option<String>,
94 + pub bcc_address: Option<String>,
93 95 pub subject: String,
94 96 pub body: String,
95 97 pub project_id: Option<ProjectId>,
@@ -406,8 +408,8 @@ pub async fn send_email(state: State<'_, Arc<AppState>>, input: SendEmailInput)
406 408 if input.subject.trim().is_empty() {
407 409 return Err(ApiError::validation("subject", "Subject is required"));
408 410 }
409 - if !is_valid_email(&input.to_address) {
410 - return Err(ApiError::validation("toAddress", "Invalid 'to' email address"));
411 + if input.to_address.trim().is_empty() {
412 + return Err(ApiError::validation("toAddress", "At least one recipient is required"));
411 413 }
412 414
413 415 let account = state.email_accounts
@@ -415,7 +417,15 @@ pub async fn send_email(state: State<'_, Arc<AppState>>, input: SendEmailInput)
415 417 .await?
416 418 .or_not_found("emailAccount", input.account_id)?;
417 419
418 - let is_reply = input.in_reply_to.is_some();
420 + let params = crate::email::smtp_client::SendParams {
421 + to: &input.to_address,
422 + cc: input.cc_address.as_deref(),
423 + bcc: input.bcc_address.as_deref(),
424 + subject: &input.subject,
425 + body: &input.body,
426 + in_reply_to: input.in_reply_to.as_deref(),
427 + references: input.references.as_deref(),
428 + };
419 429
420 430 let message_id = if uses_jmap(&account) {
421 431 return Err(ApiError::bad_request("JMAP email sending not yet implemented - use IMAP account"));
@@ -428,43 +438,17 @@ pub async fn send_email(state: State<'_, Arc<AppState>>, input: SendEmailInput)
428 438 &access_token,
429 439 true,
430 440 );
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 - }
441 + smtp_client
442 + .send_message(&params)
443 + .await
444 + .map_api_err("Failed to send email", ApiError::external_service)?
448 445 } else {
449 446 let password = get_account_password(&account)?;
450 447 let smtp_client = SmtpClient::with_password(&account, &password);
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 - }
448 + smtp_client
449 + .send_message(&params)
450 + .await
451 + .map_api_err("Failed to send email", ApiError::external_service)?
468 452 };
469 453
470 454 // For replies, join the existing thread. For new emails, start a new thread.
@@ -195,7 +195,11 @@ impl EmailProvider for ImapProvider {
195 195 subject: &str,
196 196 body: &str,
197 197 ) -> Result<String, String> {
198 - self.smtp_client.send_email(to, subject, body).await
198 + use crate::email::smtp_client::SendParams;
199 + self.smtp_client.send_message(&SendParams {
200 + to, cc: None, bcc: None, subject, body,
201 + in_reply_to: None, references: None,
202 + }).await
199 203 }
200 204
201 205 async fn archive_email(&self, email_id: &str, archive_folder: &str) -> Result<(), String> {
@@ -11,6 +11,17 @@ use lettre::{
11 11 AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
12 12 };
13 13
14 + /// Parameters for sending an email message.
15 + pub struct SendParams<'a> {
16 + pub to: &'a str,
17 + pub cc: Option<&'a str>,
18 + pub bcc: Option<&'a str>,
19 + pub subject: &'a str,
20 + pub body: &'a str,
21 + pub in_reply_to: Option<&'a str>,
22 + pub references: Option<&'a str>,
23 + }
24 +
14 25 /// Authentication method for SMTP
15 26 #[derive(Debug, Clone)]
16 27 pub enum SmtpAuth {
@@ -124,34 +135,7 @@ impl SmtpClient {
124 135 }
125 136 }
126 137
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> {
138 + pub async fn send_message(&self, params: &SendParams<'_>) -> Result<String, String> {
155 139 // Generate message ID before building so sent email and local DB agree
156 140 let message_id = format!(
157 141 "<{}.{}@{}>",
@@ -167,19 +151,37 @@ impl SmtpClient {
167 151 .parse()
168 152 .map_err(|e| format!("Invalid from address: {}", e))?,
169 153 )
170 - .to(to.parse().map_err(|e| format!("Invalid to address: {}", e))?)
171 - .subject(subject);
154 + .subject(params.subject);
155 +
156 + // To recipients (comma-separated)
157 + for addr in params.to.split(',').map(str::trim).filter(|a| !a.is_empty()) {
158 + builder = builder.to(addr.parse().map_err(|e| format!("Invalid to address '{}': {}", addr, e))?);
159 + }
160 +
161 + // CC recipients
162 + if let Some(cc) = params.cc {
163 + for addr in cc.split(',').map(str::trim).filter(|a| !a.is_empty()) {
164 + builder = builder.cc(addr.parse().map_err(|e| format!("Invalid CC address '{}': {}", addr, e))?);
165 + }
166 + }
167 +
168 + // BCC recipients
169 + if let Some(bcc) = params.bcc {
170 + for addr in bcc.split(',').map(str::trim).filter(|a| !a.is_empty()) {
171 + builder = builder.bcc(addr.parse().map_err(|e| format!("Invalid BCC address '{}': {}", addr, e))?);
172 + }
173 + }
172 174
173 - if let Some(irt) = in_reply_to {
175 + if let Some(irt) = params.in_reply_to {
174 176 builder = builder.in_reply_to(irt.to_string());
175 177 }
176 - if let Some(refs) = references {
178 + if let Some(refs) = params.references {
177 179 builder = builder.references(refs.to_string());
178 180 }
179 181
180 182 let email = builder
181 183 .header(ContentType::TEXT_PLAIN)
182 - .body(body.to_string())
184 + .body(params.body.to_string())
183 185 .map_err(|e| format!("Failed to build email: {}", e))?;
184 186
185 187 let mailer = self.build_mailer()?;