Skip to main content

max / goingson

Split commands/email into a directory: thin commands + extracted services commands/email.rs was 1144 lines mixing Tauri command wrappers, DTOs, and embedded business logic. Convert it to a commands/email/ module: - mod.rs keeps the 33 #[tauri::command] wrappers and every DTO - send.rs build_imap_client + send_email_inner (SMTP send flow) - contacts.rs create_implicit_contacts (recipient contact side-effects) - preview.rs open_email_in_browser + cleanup_stale_temp_files + html_escape Every command and pub fn stays reachable at commands::<name>: commands/mod.rs already re-exports `pub use email::*`, and email/mod.rs adds `pub use preview::*`. The extracted helpers are pub(super); `super::` paths that referred to the commands module (ContactResponse, write_private_temp) are rewritten to crate::commands::. Function set unchanged (40 fns).
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 14:19 UTC
Signed with PGP, not checked
Commit: 1c262e1021036701dd6d004d8e17660c14a1a823
Parent: 6de9507
4 files changed, +426 insertions, -400 deletions
@@ -20,59 +20,15 @@
20 20 use super::{ApiError, LinkProjectInput, OptionApiError, OptionNotFound, ResultApiError, SnoozeInput, WaitingInput};
21 21 use super::email_account::{uses_oauth_imap, get_account_password, get_valid_access_token};
22 22
23 + mod send;
24 + mod contacts;
25 + mod preview;
26 +
27 + use send::{build_imap_client, send_email_inner};
28 + pub use preview::*;
29 +
23 30 // ============ IMAP Client Helper ============
24 31
25 - /// Result of attempting to build an IMAP client for an email's account.
26 - ///
27 - /// If the email has no associated account/UID, or the account uses JMAP,
28 - /// returns `None`. If credential retrieval fails, logs a warning and
29 - /// returns `None` so the caller can fall through to the local-only path.
30 - async fn build_imap_client(
31 - state: &Arc<AppState>,
32 - account_id: EmailAccountId,
33 - operation: &str,
34 - ) -> Option<(ImapClient, String)> {
35 - let account = match state.email_accounts.get_by_id(account_id, DESKTOP_USER_ID).await {
36 - Ok(Some(a)) => a,
37 - _ => return None,
38 - };
39 -
40 - if uses_jmap(&account) {
41 - return None;
42 - }
43 -
44 - let archive_folder = account
45 - .archive_folder_name
46 - .as_deref()
47 - .unwrap_or("Archive")
48 - .to_string();
49 -
50 - let client = if uses_oauth_imap(&account) {
51 - match get_valid_access_token(state, &account).await {
52 - Ok(token) => ImapClient::with_oauth(
53 - &account.imap_server,
54 - account.imap_port as u16,
55 - &account.email_address,
56 - &token,
57 - ),
58 - Err(e) => {
59 - warn!("Failed to get OAuth token for {}: {}", operation, e);
60 - return None;
61 - }
62 - }
63 - } else {
64 - match get_account_password(&account) {
65 - Ok(password) => ImapClient::with_password(&account, &password),
66 - Err(e) => {
67 - warn!("Failed to get password for {}: {}", operation, e);
68 - return None;
69 - }
70 - }
71 - };
72 -
73 - Some((client, archive_folder))
74 - }
75 -
76 32 // ============ Types ============
77 33
78 34 #[derive(Debug, Deserialize)]
@@ -458,135 +414,6 @@
458 414 Ok(body)
459 415 }
460 416
461 - /// Opens an email in the system's default web browser.
462 - #[tauri::command]
463 - #[instrument(skip_all)]
464 - pub async fn open_email_in_browser(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<(), ApiError> {
465 - let email = state.emails.get_by_id(id, DESKTOP_USER_ID).await?
466 - .or_not_found("email", id)?;
467 -
468 - let html_content = if let Some(ref html) = email.html_body {
469 - let sanitized_body = docengine::sanitize_html(html);
470 - format!(
471 - r#"<!DOCTYPE html>
472 - <html>
473 - <head>
474 - <meta charset="utf-8">
475 - <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src https: data:;">
476 - <title>{}</title>
477 - <style>
478 - body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 2rem; }}
479 - .email-header {{ border-bottom: 1px solid #ccc; padding-bottom: 1rem; margin-bottom: 1rem; }}
480 - .email-header p {{ margin: 0.25rem 0; }}
481 - .email-label {{ font-weight: bold; color: #666; }}
482 - </style>
483 - </head>
484 - <body>
485 - <div class="email-header">
486 - <p><span class="email-label">From:</span> {}</p>
487 - <p><span class="email-label">To:</span> {}</p>
488 - <p><span class="email-label">Subject:</span> {}</p>
489 - <p><span class="email-label">Date:</span> {}</p>
490 - </div>
491 - <div class="email-body">
492 - {}
493 - </div>
494 - </body>
495 - </html>"#,
496 - html_escape(&email.subject),
497 - html_escape(&email.from),
498 - html_escape(&email.to),
499 - html_escape(&email.subject),
500 - email.received_at.format("%Y-%m-%d %H:%M:%S UTC"),
501 - sanitized_body
502 - )
503 - } else {
504 - let body_html = html_escape(&email.body).replace('\n', "<br>\n");
505 - format!(
506 - r#"<!DOCTYPE html>
507 - <html>
508 - <head>
509 - <meta charset="utf-8">
510 - <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src https: data:;">
511 - <title>{}</title>
512 - <style>
513 - body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 2rem; line-height: 1.6; }}
514 - .email-header {{ border-bottom: 1px solid #ccc; padding-bottom: 1rem; margin-bottom: 1rem; }}
515 - .email-header p {{ margin: 0.25rem 0; }}
516 - .email-label {{ font-weight: bold; color: #666; }}
517 - .email-body {{ white-space: pre-wrap; }}
518 - </style>
519 - </head>
520 - <body>
521 - <div class="email-header">
522 - <p><span class="email-label">From:</span> {}</p>
523 - <p><span class="email-label">To:</span> {}</p>
524 - <p><span class="email-label">Subject:</span> {}</p>
525 - <p><span class="email-label">Date:</span> {}</p>
526 - </div>
527 - <div class="email-body">{}</div>
528 - </body>
529 - </html>"#,
530 - html_escape(&email.subject),
531 - html_escape(&email.from),
532 - html_escape(&email.to),
533 - html_escape(&email.subject),
534 - email.received_at.format("%Y-%m-%d %H:%M:%S UTC"),
535 - body_html
536 - )
537 - };
538 -
539 - let temp_dir = std::env::temp_dir();
540 - let file_name = format!("goingson_email_{}_{}.html", id, uuid::Uuid::new_v4().simple());
541 - let file_path = temp_dir.join(file_name);
542 -
543 - // Written owner-only (0600): the email body/subject/sender must not be
544 - // readable by other local users via the world-readable system temp dir.
545 - let write_path = file_path.clone();
546 - tokio::task::spawn_blocking(move || super::write_private_temp(&write_path, html_content.as_bytes()))
547 - .await
548 - .map_api_err("Task join error", ApiError::internal)?
549 - .map_api_err("Failed to write temp file", ApiError::internal)?;
550 -
551 - let path = file_path.clone();
552 - tokio::task::spawn_blocking(move || open::that(&path)).await
553 - .map_api_err("Task join error", ApiError::internal)?
554 - .map_api_err("Failed to open browser", ApiError::internal)?;
555 -
556 - // Clean up temp file after a delay to give the browser time to load it
557 - tokio::spawn(async move {
558 - tokio::time::sleep(std::time::Duration::from_secs(30)).await;
559 - let _ = tokio::fs::remove_file(&file_path).await;
560 - });
561 -
562 - Ok(())
563 - }
564 -
565 - /// Remove stale `goingson_email_*.html` temp files from previous sessions.
566 - pub async fn cleanup_stale_temp_files() {
567 - let temp_dir = std::env::temp_dir();
568 - let mut entries = match tokio::fs::read_dir(&temp_dir).await {
569 - Ok(e) => e,
570 - Err(_) => return,
571 - };
572 - while let Ok(Some(entry)) = entries.next_entry().await {
573 - if let Some(name) = entry.file_name().to_str()
574 - && name.starts_with("goingson_email_") && name.ends_with(".html") {
575 - let _ = tokio::fs::remove_file(entry.path()).await;
576 - }
577 - }
578 - }
579 -
580 - /// Escape HTML special characters to prevent XSS when injecting user
581 - /// content (email bodies, subjects) into the browser preview template.
582 - fn html_escape(s: &str) -> String {
583 - s.replace('&', "&amp;")
584 - .replace('<', "&lt;")
585 - .replace('>', "&gt;")
586 - .replace('"', "&quot;")
587 - .replace('\'', "&#39;")
588 - }
589 -
590 417 /// Creates a new email record (draft or imported).
591 418 #[tauri::command]
592 419 #[instrument(skip_all)]
@@ -699,226 +526,6 @@
699 526 send_email_inner(&state, input).await
700 527 }
701 528
702 - /// Core send logic shared by `send_email` and `send_email_draft`.
703 - ///
704 - /// Flow: (1) validate inputs, (2) look up account + credentials,
705 - /// (3) send via SMTP (OAuth or password), (4) save outgoing record to
706 - /// the local database with `is_outgoing=true` and `source_folder="Sent"`.
707 - async fn send_email_inner(state: &Arc<AppState>, input: SendEmailInput) -> Result<SendEmailResponse, ApiError> {
708 - if input.subject.trim().is_empty() {
709 - return Err(ApiError::validation("subject", "Subject is required"));
710 - }
711 - if input.to_address.trim().is_empty() {
712 - return Err(ApiError::validation("toAddress", "At least one recipient is required"));
713 - }
714 -
715 - let account = state.email_accounts
716 - .get_by_id(input.account_id, DESKTOP_USER_ID)
717 - .await?
718 - .or_not_found("emailAccount", input.account_id)?;
719 -
720 - // Read attachment files. Cap the combined size before buffering them all into
721 - // memory (and base64-encoding ~1.33x on top) so a huge selection can't blow up
722 - // memory or get rejected by the server after a long upload (Perf minor).
723 - const MAX_TOTAL_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024;
724 - use crate::email::smtp_client::AttachmentFile;
725 - let mut attachment_files = Vec::new();
726 - let mut total_bytes: u64 = 0;
727 - for path_str in &input.attachment_paths {
728 - let path = std::path::Path::new(path_str);
729 - if !path.is_file() {
730 - return Err(ApiError::validation("attachmentPaths", format!("File not found: {}", path_str)));
731 - }
732 - let meta = tokio::fs::metadata(path).await
733 - .map_api_err("Failed to read attachment file", ApiError::internal)?;
734 - total_bytes = total_bytes.saturating_add(meta.len());
735 - if total_bytes > MAX_TOTAL_ATTACHMENT_BYTES {
736 - return Err(ApiError::validation(
737 - "attachmentPaths",
738 - format!("Attachments exceed the {} MB total limit", MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024)),
739 - ));
740 - }
741 - let data = tokio::fs::read(path).await
742 - .map_api_err("Failed to read attachment file", ApiError::internal)?;
743 - let filename = path.file_name()
744 - .and_then(|n| n.to_str())
745 - .unwrap_or("attachment")
746 - .to_string();
747 - let mime_type = goingson_core::mime_from_extension(&filename).to_string();
748 - attachment_files.push(AttachmentFile { filename, mime_type, data });
749 - }
750 -
751 - let params = crate::email::smtp_client::SendParams {
752 - to: &input.to_address,
753 - cc: input.cc_address.as_deref(),
754 - bcc: input.bcc_address.as_deref(),
755 - subject: &input.subject,
756 - body: &input.body,
757 - in_reply_to: input.in_reply_to.as_deref(),
758 - references: input.references.as_deref(),
759 - attachments: attachment_files,
760 - };
761 -
762 - let message_id = if uses_jmap(&account) {
763 - return Err(ApiError::bad_request("JMAP email sending not yet implemented - use IMAP account"));
764 - } else if uses_oauth_imap(&account) {
765 - let access_token = get_valid_access_token(state, &account).await?;
766 - let smtp_client = SmtpClient::with_oauth(
767 - &account.smtp_server,
768 - account.smtp_port as u16,
769 - &account.email_address,
770 - &access_token,
771 - );
772 - smtp_client
773 - .send_message(&params)
774 - .await
775 - .map_api_err("Failed to send email", ApiError::external_service)?
776 - } else {
777 - let password = get_account_password(&account)?;
778 - let smtp_client = SmtpClient::with_password(&account, &password);
779 - smtp_client
780 - .send_message(&params)
781 - .await
782 - .map_api_err("Failed to send email", ApiError::external_service)?
783 - };
784 -
785 - // For replies, join the existing thread. For new emails, start a new thread.
786 - let thread_id = input.thread_id.unwrap_or_else(|| message_id.clone());
787 -
788 - // Capture recipient addresses before they're moved into new_email
789 - let to_for_contacts = input.to_address.clone();
790 - let cc_for_contacts = input.cc_address.clone();
791 - let bcc_for_contacts = input.bcc_address.clone();
792 -
793 - let new_email = NewEmailWithTracking {
794 - project_id: input.project_id,
795 - from_address: account.email_address.clone(),
796 - to_address: input.to_address,
797 - subject: input.subject,
798 - body: input.body,
799 - html_body: None,
800 - is_read: true,
801 - is_archived: false,
802 - received_at: Some(Utc::now()),
803 - message_id: Some(message_id.clone()),
804 - in_reply_to: input.in_reply_to,
805 - thread_id: Some(thread_id),
806 - imap_uid: None,
807 - source_folder: Some("Sent".to_string()),
808 - email_account_id: Some(input.account_id),
809 - is_outgoing: true,
810 - attachment_meta: None,
811 - body_truncated: false,
812 - jmap_id: None,
813 - };
814 -
815 - let saved = state.emails.create_with_tracking(DESKTOP_USER_ID, new_email).await?;
816 -
817 - // Auto-create implicit contacts for unknown recipients
818 - let new_implicit_contacts = create_implicit_contacts(
819 - state,
820 - &to_for_contacts,
821 - cc_for_contacts.as_deref(),
822 - bcc_for_contacts.as_deref(),
823 - &account.email_address,
824 - ).await;
825 -
826 - Ok(SendEmailResponse {
827 - success: true,
828 - message_id: Some(message_id),
829 - saved_email: EmailResponse::from(saved),
830 - new_implicit_contacts,
831 - })
832 - }
833 -
834 - /// Create implicit contacts for recipients that don't have an existing contact.
835 - /// Errors are logged and swallowed — never fails the send.
836 - async fn create_implicit_contacts(
837 - state: &std::sync::Arc<AppState>,
838 - to: &str,
839 - cc: Option<&str>,
840 - bcc: Option<&str>,
841 - sender_email: &str,
842 - ) -> Vec<super::ContactResponse> {
843 - use goingson_db_sqlite::utils::is_valid_email;
844 - use goingson_core::{NewContact, NewContactEmail};
845 - use std::collections::HashSet;
846 -
847 - // Collect all unique recipient addresses
848 - let mut addresses = HashSet::new();
849 - for field in [Some(to), cc, bcc].into_iter().flatten() {
850 - for addr in field.split(',').map(str::trim).filter(|a| !a.is_empty()) {
851 - let lower = addr.to_lowercase();
852 - if lower != sender_email.to_lowercase() && is_valid_email(addr) {
853 - addresses.insert((addr.to_string(), lower));
854 - }
855 - }
856 - }
857 -
858 - let mut new_contacts = Vec::new();
859 -
860 - for (addr, _lower) in addresses {
861 - // Check if a contact already exists for this address
862 - match state.contacts.find_by_email(DESKTOP_USER_ID, &addr).await {
863 - Ok(Some(_)) => continue, // already exists
864 - Ok(None) => {} // proceed to create
865 - Err(e) => {
866 - tracing::warn!("Failed to check contact for {}: {}", addr, e);
867 - continue;
868 - }
869 - }
870 -
871 - // Derive display name from the local part of the email address
872 - let display_name = addr.split('@').next().unwrap_or(&addr)
873 - .replace(['.', '_'], " ")
874 - .split_whitespace()
875 - .map(|w| {
876 - let mut c = w.chars();
877 - match c.next() {
878 - None => String::new(),
879 - Some(f) => f.to_uppercase().to_string() + c.as_str(),
880 - }
881 - })
882 - .collect::<Vec<_>>()
883 - .join(" ");
884 -
885 - let new_contact = NewContact {
886 - display_name,
887 - nickname: None,
888 - company: None,
889 - title: None,
890 - notes: String::new(),
891 - tags: vec![],
892 - birthday: None,
893 - timezone: None,
894 - is_implicit: true,
895 - };
896 -
897 - match state.contacts.create(DESKTOP_USER_ID, new_contact).await {
898 - Ok(contact) => {
899 - let email_entry = NewContactEmail {
900 - address: addr.clone(),
901 - label: String::new(),
902 - is_primary: true,
903 - };
904 - if let Err(e) = state.contacts.add_email(contact.id, DESKTOP_USER_ID, email_entry).await {
905 - tracing::warn!("Failed to add email to implicit contact: {}", e);
906 - }
907 - // Re-fetch to get hydrated contact with email sub-collection
908 - match state.contacts.get_by_id(contact.id, DESKTOP_USER_ID).await {
909 - Ok(Some(c)) => new_contacts.push(super::ContactResponse::from(c)),
910 - _ => new_contacts.push(super::ContactResponse::from(contact)),
911 - }
912 - }
913 - Err(e) => {
914 - tracing::warn!("Failed to create implicit contact for {}: {}", addr, e);
915 - }
916 - }
917 - }
918 -
919 - new_contacts
920 - }
921 -
922 529 /// Set labels on an email.
923 530 #[tauri::command]
924 531 #[instrument(skip_all)]
@@ -1,0 +1,92 @@
1 + //! Contact side-effects of sending: create implicit contacts for recipients
2 + //! that don't already have one. Best-effort — never fails the send.
3 +
4 + use super::*;
5 +
6 + /// Create implicit contacts for recipients that don't have an existing contact.
7 + /// Errors are logged and swallowed — never fails the send.
8 + pub(super) async fn create_implicit_contacts(
9 + state: &std::sync::Arc<AppState>,
10 + to: &str,
11 + cc: Option<&str>,
12 + bcc: Option<&str>,
13 + sender_email: &str,
14 + ) -> Vec<crate::commands::ContactResponse> {
15 + use goingson_db_sqlite::utils::is_valid_email;
16 + use goingson_core::{NewContact, NewContactEmail};
17 + use std::collections::HashSet;
18 +
19 + // Collect all unique recipient addresses
20 + let mut addresses = HashSet::new();
21 + for field in [Some(to), cc, bcc].into_iter().flatten() {
22 + for addr in field.split(',').map(str::trim).filter(|a| !a.is_empty()) {
23 + let lower = addr.to_lowercase();
24 + if lower != sender_email.to_lowercase() && is_valid_email(addr) {
25 + addresses.insert((addr.to_string(), lower));
26 + }
27 + }
28 + }
29 +
30 + let mut new_contacts = Vec::new();
31 +
32 + for (addr, _lower) in addresses {
33 + // Check if a contact already exists for this address
34 + match state.contacts.find_by_email(DESKTOP_USER_ID, &addr).await {
35 + Ok(Some(_)) => continue, // already exists
36 + Ok(None) => {} // proceed to create
37 + Err(e) => {
38 + tracing::warn!("Failed to check contact for {}: {}", addr, e);
39 + continue;
40 + }
41 + }
42 +
43 + // Derive display name from the local part of the email address
44 + let display_name = addr.split('@').next().unwrap_or(&addr)
45 + .replace(['.', '_'], " ")
46 + .split_whitespace()
47 + .map(|w| {
48 + let mut c = w.chars();
49 + match c.next() {
50 + None => String::new(),
51 + Some(f) => f.to_uppercase().to_string() + c.as_str(),
52 + }
53 + })
54 + .collect::<Vec<_>>()
55 + .join(" ");
56 +
57 + let new_contact = NewContact {
58 + display_name,
59 + nickname: None,
60 + company: None,
61 + title: None,
62 + notes: String::new(),
63 + tags: vec![],
64 + birthday: None,
65 + timezone: None,
66 + is_implicit: true,
67 + };
68 +
69 + match state.contacts.create(DESKTOP_USER_ID, new_contact).await {
70 + Ok(contact) => {
71 + let email_entry = NewContactEmail {
72 + address: addr.clone(),
73 + label: String::new(),
74 + is_primary: true,
75 + };
76 + if let Err(e) = state.contacts.add_email(contact.id, DESKTOP_USER_ID, email_entry).await {
77 + tracing::warn!("Failed to add email to implicit contact: {}", e);
78 + }
79 + // Re-fetch to get hydrated contact with email sub-collection
80 + match state.contacts.get_by_id(contact.id, DESKTOP_USER_ID).await {
81 + Ok(Some(c)) => new_contacts.push(crate::commands::ContactResponse::from(c)),
82 + _ => new_contacts.push(crate::commands::ContactResponse::from(contact)),
83 + }
84 + }
85 + Err(e) => {
86 + tracing::warn!("Failed to create implicit contact for {}: {}", addr, e);
87 + }
88 + }
89 + }
90 +
91 + new_contacts
92 + }
@@ -1,0 +1,136 @@
1 + //! Email preview: render an email to a self-contained HTML file, open it in
2 + //! the system browser, and reap stale preview temp files. Split out of the
3 + //! email command module.
4 +
5 + use tracing::instrument;
6 +
7 + use super::*;
8 +
9 + /// Opens an email in the system's default web browser.
10 + #[tauri::command]
11 + #[instrument(skip_all)]
12 + pub async fn open_email_in_browser(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<(), ApiError> {
13 + let email = state.emails.get_by_id(id, DESKTOP_USER_ID).await?
14 + .or_not_found("email", id)?;
15 +
16 + let html_content = if let Some(ref html) = email.html_body {
17 + let sanitized_body = docengine::sanitize_html(html);
18 + format!(
19 + r#"<!DOCTYPE html>
20 + <html>
21 + <head>
22 + <meta charset="utf-8">
23 + <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src https: data:;">
24 + <title>{}</title>
25 + <style>
26 + body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 2rem; }}
27 + .email-header {{ border-bottom: 1px solid #ccc; padding-bottom: 1rem; margin-bottom: 1rem; }}
28 + .email-header p {{ margin: 0.25rem 0; }}
29 + .email-label {{ font-weight: bold; color: #666; }}
30 + </style>
31 + </head>
32 + <body>
33 + <div class="email-header">
34 + <p><span class="email-label">From:</span> {}</p>
35 + <p><span class="email-label">To:</span> {}</p>
36 + <p><span class="email-label">Subject:</span> {}</p>
37 + <p><span class="email-label">Date:</span> {}</p>
38 + </div>
39 + <div class="email-body">
40 + {}
41 + </div>
42 + </body>
43 + </html>"#,
44 + html_escape(&email.subject),
45 + html_escape(&email.from),
46 + html_escape(&email.to),
47 + html_escape(&email.subject),
48 + email.received_at.format("%Y-%m-%d %H:%M:%S UTC"),
49 + sanitized_body
50 + )
51 + } else {
52 + let body_html = html_escape(&email.body).replace('\n', "<br>\n");
53 + format!(
54 + r#"<!DOCTYPE html>
55 + <html>
56 + <head>
57 + <meta charset="utf-8">
58 + <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src https: data:;">
59 + <title>{}</title>
60 + <style>
61 + body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 2rem; line-height: 1.6; }}
62 + .email-header {{ border-bottom: 1px solid #ccc; padding-bottom: 1rem; margin-bottom: 1rem; }}
63 + .email-header p {{ margin: 0.25rem 0; }}
64 + .email-label {{ font-weight: bold; color: #666; }}
65 + .email-body {{ white-space: pre-wrap; }}
66 + </style>
67 + </head>
68 + <body>
69 + <div class="email-header">
70 + <p><span class="email-label">From:</span> {}</p>
71 + <p><span class="email-label">To:</span> {}</p>
72 + <p><span class="email-label">Subject:</span> {}</p>
73 + <p><span class="email-label">Date:</span> {}</p>
74 + </div>
75 + <div class="email-body">{}</div>
76 + </body>
77 + </html>"#,
78 + html_escape(&email.subject),
79 + html_escape(&email.from),
80 + html_escape(&email.to),
81 + html_escape(&email.subject),
82 + email.received_at.format("%Y-%m-%d %H:%M:%S UTC"),
83 + body_html
84 + )
85 + };
86 +
87 + let temp_dir = std::env::temp_dir();
88 + let file_name = format!("goingson_email_{}_{}.html", id, uuid::Uuid::new_v4().simple());
89 + let file_path = temp_dir.join(file_name);
90 +
91 + // Written owner-only (0600): the email body/subject/sender must not be
92 + // readable by other local users via the world-readable system temp dir.
93 + let write_path = file_path.clone();
94 + tokio::task::spawn_blocking(move || crate::commands::write_private_temp(&write_path, html_content.as_bytes()))
95 + .await
96 + .map_api_err("Task join error", ApiError::internal)?
97 + .map_api_err("Failed to write temp file", ApiError::internal)?;
98 +
99 + let path = file_path.clone();
100 + tokio::task::spawn_blocking(move || open::that(&path)).await
101 + .map_api_err("Task join error", ApiError::internal)?
102 + .map_api_err("Failed to open browser", ApiError::internal)?;
103 +
104 + // Clean up temp file after a delay to give the browser time to load it
105 + tokio::spawn(async move {
106 + tokio::time::sleep(std::time::Duration::from_secs(30)).await;
107 + let _ = tokio::fs::remove_file(&file_path).await;
108 + });
109 +
110 + Ok(())
111 + }
112 +
113 + /// Remove stale `goingson_email_*.html` temp files from previous sessions.
114 + pub async fn cleanup_stale_temp_files() {
115 + let temp_dir = std::env::temp_dir();
116 + let mut entries = match tokio::fs::read_dir(&temp_dir).await {
117 + Ok(e) => e,
118 + Err(_) => return,
119 + };
120 + while let Ok(Some(entry)) = entries.next_entry().await {
121 + if let Some(name) = entry.file_name().to_str()
122 + && name.starts_with("goingson_email_") && name.ends_with(".html") {
123 + let _ = tokio::fs::remove_file(entry.path()).await;
124 + }
125 + }
126 + }
127 +
128 + /// Escape HTML special characters to prevent XSS when injecting user
129 + /// content (email bodies, subjects) into the browser preview template.
130 + fn html_escape(s: &str) -> String {
131 + s.replace('&', "&amp;")
132 + .replace('<', "&lt;")
133 + .replace('>', "&gt;")
134 + .replace('"', "&quot;")
135 + .replace('\'', "&#39;")
136 + }
@@ -1,0 +1,191 @@
1 + //! Email send service: build the IMAP client for mailbox mutations, and the
2 + //! SMTP send flow (validate, credentials, send, persist the outgoing record).
3 + //! Split out of the email command module.
4 +
5 + use tracing::warn;
6 +
7 + use super::*;
8 + use super::contacts::create_implicit_contacts;
9 +
10 + /// Result of attempting to build an IMAP client for an email's account.
11 + ///
12 + /// If the email has no associated account/UID, or the account uses JMAP,
13 + /// returns `None`. If credential retrieval fails, logs a warning and
14 + /// returns `None` so the caller can fall through to the local-only path.
15 + pub(super) async fn build_imap_client(
16 + state: &Arc<AppState>,
17 + account_id: EmailAccountId,
18 + operation: &str,
19 + ) -> Option<(ImapClient, String)> {
20 + let account = match state.email_accounts.get_by_id(account_id, DESKTOP_USER_ID).await {
21 + Ok(Some(a)) => a,
22 + _ => return None,
23 + };
24 +
25 + if uses_jmap(&account) {
26 + return None;
27 + }
28 +
29 + let archive_folder = account
30 + .archive_folder_name
31 + .as_deref()
32 + .unwrap_or("Archive")
33 + .to_string();
34 +
35 + let client = if uses_oauth_imap(&account) {
36 + match get_valid_access_token(state, &account).await {
37 + Ok(token) => ImapClient::with_oauth(
38 + &account.imap_server,
39 + account.imap_port as u16,
40 + &account.email_address,
41 + &token,
42 + ),
43 + Err(e) => {
44 + warn!("Failed to get OAuth token for {}: {}", operation, e);
45 + return None;
46 + }
47 + }
48 + } else {
49 + match get_account_password(&account) {
50 + Ok(password) => ImapClient::with_password(&account, &password),
51 + Err(e) => {
52 + warn!("Failed to get password for {}: {}", operation, e);
53 + return None;
54 + }
55 + }
56 + };
57 +
58 + Some((client, archive_folder))
59 + }
60 +
61 + /// Core send logic shared by `send_email` and `send_email_draft`.
62 + ///
63 + /// Flow: (1) validate inputs, (2) look up account + credentials,
64 + /// (3) send via SMTP (OAuth or password), (4) save outgoing record to
65 + /// the local database with `is_outgoing=true` and `source_folder="Sent"`.
66 + pub(super) async fn send_email_inner(state: &Arc<AppState>, input: SendEmailInput) -> Result<SendEmailResponse, ApiError> {
67 + if input.subject.trim().is_empty() {
68 + return Err(ApiError::validation("subject", "Subject is required"));
69 + }
70 + if input.to_address.trim().is_empty() {
71 + return Err(ApiError::validation("toAddress", "At least one recipient is required"));
72 + }
73 +
74 + let account = state.email_accounts
75 + .get_by_id(input.account_id, DESKTOP_USER_ID)
76 + .await?
77 + .or_not_found("emailAccount", input.account_id)?;
78 +
79 + // Read attachment files. Cap the combined size before buffering them all into
80 + // memory (and base64-encoding ~1.33x on top) so a huge selection can't blow up
81 + // memory or get rejected by the server after a long upload (Perf minor).
82 + const MAX_TOTAL_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024;
83 + use crate::email::smtp_client::AttachmentFile;
84 + let mut attachment_files = Vec::new();
85 + let mut total_bytes: u64 = 0;
86 + for path_str in &input.attachment_paths {
87 + let path = std::path::Path::new(path_str);
88 + if !path.is_file() {
89 + return Err(ApiError::validation("attachmentPaths", format!("File not found: {}", path_str)));
90 + }
91 + let meta = tokio::fs::metadata(path).await
92 + .map_api_err("Failed to read attachment file", ApiError::internal)?;
93 + total_bytes = total_bytes.saturating_add(meta.len());
94 + if total_bytes > MAX_TOTAL_ATTACHMENT_BYTES {
95 + return Err(ApiError::validation(
96 + "attachmentPaths",
97 + format!("Attachments exceed the {} MB total limit", MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024)),
98 + ));
99 + }
100 + let data = tokio::fs::read(path).await
101 + .map_api_err("Failed to read attachment file", ApiError::internal)?;
102 + let filename = path.file_name()
103 + .and_then(|n| n.to_str())
104 + .unwrap_or("attachment")
105 + .to_string();
106 + let mime_type = goingson_core::mime_from_extension(&filename).to_string();
107 + attachment_files.push(AttachmentFile { filename, mime_type, data });
108 + }
109 +
110 + let params = crate::email::smtp_client::SendParams {
111 + to: &input.to_address,
112 + cc: input.cc_address.as_deref(),
113 + bcc: input.bcc_address.as_deref(),
114 + subject: &input.subject,
115 + body: &input.body,
116 + in_reply_to: input.in_reply_to.as_deref(),
117 + references: input.references.as_deref(),
118 + attachments: attachment_files,
119 + };
120 +
121 + let message_id = if uses_jmap(&account) {
122 + return Err(ApiError::bad_request("JMAP email sending not yet implemented - use IMAP account"));
123 + } else if uses_oauth_imap(&account) {
124 + let access_token = get_valid_access_token(state, &account).await?;
125 + let smtp_client = SmtpClient::with_oauth(
126 + &account.smtp_server,
127 + account.smtp_port as u16,
128 + &account.email_address,
129 + &access_token,
130 + );
131 + smtp_client
132 + .send_message(&params)
133 + .await
134 + .map_api_err("Failed to send email", ApiError::external_service)?
135 + } else {
136 + let password = get_account_password(&account)?;
137 + let smtp_client = SmtpClient::with_password(&account, &password);
138 + smtp_client
139 + .send_message(&params)
140 + .await
141 + .map_api_err("Failed to send email", ApiError::external_service)?
142 + };
143 +
144 + // For replies, join the existing thread. For new emails, start a new thread.
145 + let thread_id = input.thread_id.unwrap_or_else(|| message_id.clone());
146 +
147 + // Capture recipient addresses before they're moved into new_email
148 + let to_for_contacts = input.to_address.clone();
149 + let cc_for_contacts = input.cc_address.clone();
150 + let bcc_for_contacts = input.bcc_address.clone();
151 +
152 + let new_email = NewEmailWithTracking {
153 + project_id: input.project_id,
154 + from_address: account.email_address.clone(),
155 + to_address: input.to_address,
156 + subject: input.subject,
157 + body: input.body,
158 + html_body: None,
159 + is_read: true,
160 + is_archived: false,
161 + received_at: Some(Utc::now()),
162 + message_id: Some(message_id.clone()),
163 + in_reply_to: input.in_reply_to,
164 + thread_id: Some(thread_id),
165 + imap_uid: None,
166 + source_folder: Some("Sent".to_string()),
167 + email_account_id: Some(input.account_id),
168 + is_outgoing: true,
169 + attachment_meta: None,
170 + body_truncated: false,
171 + jmap_id: None,
172 + };
173 +
174 + let saved = state.emails.create_with_tracking(DESKTOP_USER_ID, new_email).await?;
175 +
176 + // Auto-create implicit contacts for unknown recipients
177 + let new_implicit_contacts = create_implicit_contacts(
178 + state,
179 + &to_for_contacts,
180 + cc_for_contacts.as_deref(),
181 + bcc_for_contacts.as_deref(),
182 + &account.email_address,
183 + ).await;
184 +
185 + Ok(SendEmailResponse {
186 + success: true,
187 + message_id: Some(message_id),
188 + saved_email: EmailResponse::from(saved),
189 + new_implicit_contacts,
190 + })
191 + }