//! Email preview: render an email to a self-contained HTML file, open it in //! the system browser, and reap stale preview temp files. Split out of the //! email command module. use tracing::instrument; use super::{ ApiError, AppState, Arc, DESKTOP_USER_ID, EmailId, OptionNotFound, ResultApiError, State, }; /// Opens an email in the system's default web browser. #[tauri::command] #[instrument(skip_all)] pub async fn open_email_in_browser( state: State<'_, Arc>, id: EmailId, ) -> Result<(), ApiError> { let email = state .emails .get_by_id(id, DESKTOP_USER_ID) .await? .or_not_found("email", id)?; let html_content = if let Some(ref html) = email.html_body { let sanitized_body = docengine::sanitize_html(html); format!( r#" {}

{}

{}

{}

{}

{}
"#, html_escape(&email.subject), html_escape(&email.from), html_escape(&email.to), html_escape(&email.subject), email.received_at.format("%Y-%m-%d %H:%M:%S UTC"), sanitized_body ) } else { let body_html = html_escape(&email.body).replace('\n', "
\n"); format!( r#" {}

{}

{}

{}

{}

{}
"#, html_escape(&email.subject), html_escape(&email.from), html_escape(&email.to), html_escape(&email.subject), email.received_at.format("%Y-%m-%d %H:%M:%S UTC"), body_html ) }; let temp_dir = std::env::temp_dir(); let file_name = format!( "goingson_email_{}_{}.html", id, uuid::Uuid::new_v4().simple() ); let file_path = temp_dir.join(file_name); // Written owner-only (0600): the email body/subject/sender must not be // readable by other local users via the world-readable system temp dir. let write_path = file_path.clone(); tokio::task::spawn_blocking(move || { crate::commands::write_private_temp(&write_path, html_content.as_bytes()) }) .await .map_api_err("Task join error", ApiError::internal)? .map_api_err("Failed to write temp file", ApiError::internal)?; let path = file_path.clone(); tokio::task::spawn_blocking(move || open::that(&path)) .await .map_api_err("Task join error", ApiError::internal)? .map_api_err("Failed to open browser", ApiError::internal)?; // Clean up temp file after a delay to give the browser time to load it tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(30)).await; let _ = tokio::fs::remove_file(&file_path).await; }); Ok(()) } /// Remove stale `goingson_email_*.html` temp files from previous sessions. pub async fn cleanup_stale_temp_files() { let temp_dir = std::env::temp_dir(); let Ok(mut entries) = tokio::fs::read_dir(&temp_dir).await else { return; }; while let Ok(Some(entry)) = entries.next_entry().await { if let Some(name) = entry.file_name().to_str() && name.starts_with("goingson_email_") && std::path::Path::new(name) .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("html")) { let _ = tokio::fs::remove_file(entry.path()).await; } } } /// Escape HTML special characters to prevent XSS when injecting user /// content (email bodies, subjects) into the browser preview template. fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") }