Skip to main content

max / goingson

5.1 KB · 137 lines History Blame Raw
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 }
137