max / goingson
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(¶ms) | |
| 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(¶ms) | |
| 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()?; |