Skip to main content

max / goingson

5.4 KB · 155 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 ApiError, AppState, Arc, DESKTOP_USER_ID, EmailId, OptionNotFound, ResultApiError, State,
9 };
10
11 /// Opens an email in the system's default web browser.
12 #[tauri::command]
13 #[instrument(skip_all)]
14 pub async fn open_email_in_browser(
15 state: State<'_, Arc<AppState>>,
16 id: EmailId,
17 ) -> Result<(), ApiError> {
18 let email = state
19 .emails
20 .get_by_id(id, DESKTOP_USER_ID)
21 .await?
22 .or_not_found("email", id)?;
23
24 let html_content = if let Some(ref html) = email.html_body {
25 let sanitized_body = docengine::sanitize_html(html);
26 format!(
27 r#"<!DOCTYPE html>
28 <html>
29 <head>
30 <meta charset="utf-8">
31 <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src https: data:;">
32 <title>{}</title>
33 <style>
34 body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 2rem; }}
35 .email-header {{ border-bottom: 1px solid #ccc; padding-bottom: 1rem; margin-bottom: 1rem; }}
36 .email-header p {{ margin: 0.25rem 0; }}
37 .email-label {{ font-weight: bold; color: #666; }}
38 </style>
39 </head>
40 <body>
41 <div class="email-header">
42 <p><span class="email-label">From:</span> {}</p>
43 <p><span class="email-label">To:</span> {}</p>
44 <p><span class="email-label">Subject:</span> {}</p>
45 <p><span class="email-label">Date:</span> {}</p>
46 </div>
47 <div class="email-body">
48 {}
49 </div>
50 </body>
51 </html>"#,
52 html_escape(&email.subject),
53 html_escape(&email.from),
54 html_escape(&email.to),
55 html_escape(&email.subject),
56 email.received_at.format("%Y-%m-%d %H:%M:%S UTC"),
57 sanitized_body
58 )
59 } else {
60 let body_html = html_escape(&email.body).replace('\n', "<br>\n");
61 format!(
62 r#"<!DOCTYPE html>
63 <html>
64 <head>
65 <meta charset="utf-8">
66 <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src https: data:;">
67 <title>{}</title>
68 <style>
69 body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 2rem; line-height: 1.6; }}
70 .email-header {{ border-bottom: 1px solid #ccc; padding-bottom: 1rem; margin-bottom: 1rem; }}
71 .email-header p {{ margin: 0.25rem 0; }}
72 .email-label {{ font-weight: bold; color: #666; }}
73 .email-body {{ white-space: pre-wrap; }}
74 </style>
75 </head>
76 <body>
77 <div class="email-header">
78 <p><span class="email-label">From:</span> {}</p>
79 <p><span class="email-label">To:</span> {}</p>
80 <p><span class="email-label">Subject:</span> {}</p>
81 <p><span class="email-label">Date:</span> {}</p>
82 </div>
83 <div class="email-body">{}</div>
84 </body>
85 </html>"#,
86 html_escape(&email.subject),
87 html_escape(&email.from),
88 html_escape(&email.to),
89 html_escape(&email.subject),
90 email.received_at.format("%Y-%m-%d %H:%M:%S UTC"),
91 body_html
92 )
93 };
94
95 let temp_dir = std::env::temp_dir();
96 let file_name = format!(
97 "goingson_email_{}_{}.html",
98 id,
99 uuid::Uuid::new_v4().simple()
100 );
101 let file_path = temp_dir.join(file_name);
102
103 // Written owner-only (0600): the email body/subject/sender must not be
104 // readable by other local users via the world-readable system temp dir.
105 let write_path = file_path.clone();
106 tokio::task::spawn_blocking(move || {
107 crate::commands::write_private_temp(&write_path, html_content.as_bytes())
108 })
109 .await
110 .map_api_err("Task join error", ApiError::internal)?
111 .map_api_err("Failed to write temp file", ApiError::internal)?;
112
113 let path = file_path.clone();
114 tokio::task::spawn_blocking(move || open::that(&path))
115 .await
116 .map_api_err("Task join error", ApiError::internal)?
117 .map_api_err("Failed to open browser", ApiError::internal)?;
118
119 // Clean up temp file after a delay to give the browser time to load it
120 tokio::spawn(async move {
121 tokio::time::sleep(std::time::Duration::from_secs(30)).await;
122 let _ = tokio::fs::remove_file(&file_path).await;
123 });
124
125 Ok(())
126 }
127
128 /// Remove stale `goingson_email_*.html` temp files from previous sessions.
129 pub async fn cleanup_stale_temp_files() {
130 let temp_dir = std::env::temp_dir();
131 let Ok(mut entries) = tokio::fs::read_dir(&temp_dir).await else {
132 return;
133 };
134 while let Ok(Some(entry)) = entries.next_entry().await {
135 if let Some(name) = entry.file_name().to_str()
136 && name.starts_with("goingson_email_")
137 && std::path::Path::new(name)
138 .extension()
139 .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
140 {
141 let _ = tokio::fs::remove_file(entry.path()).await;
142 }
143 }
144 }
145
146 /// Escape HTML special characters to prevent XSS when injecting user
147 /// content (email bodies, subjects) into the browser preview template.
148 fn html_escape(s: &str) -> String {
149 s.replace('&', "&amp;")
150 .replace('<', "&lt;")
151 .replace('>', "&gt;")
152 .replace('"', "&quot;")
153 .replace('\'', "&#39;")
154 }
155