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