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
Commit: 1c262e1021036701dd6d004d8e17660c14a1a823
Parent: 6de9507
5 files changed, +919 insertions, -500 deletions
@@ -1,1144 +0,0 @@
1 - //! Email management commands.
2 - //!
3 - //! Provides CRUD operations and status management for emails.
4 - //! Email account management is in `email_account.rs`;
5 - //! sync operations are in `email_sync.rs`.
6 -
7 - use chrono::{Utc};
8 - use serde::{Deserialize, Serialize};
9 - use std::sync::Arc;
10 - use tauri::State;
11 -
12 - use tracing::{instrument, warn};
13 -
14 - use goingson_core::{AttachmentMeta, Email, EmailAccountId, EmailId, NewEmail, NewEmailWithTracking, ProjectId};
15 -
16 - use crate::email::{uses_jmap, ImapClient, SmtpClient};
17 - use crate::state::{AppState, DESKTOP_USER_ID};
18 - use goingson_db_sqlite::utils::is_valid_email;
19 - use goingson_core::date_utils::{format_relative_future, format_elapsed_time};
20 - use super::{ApiError, LinkProjectInput, OptionApiError, OptionNotFound, ResultApiError, SnoozeInput, WaitingInput};
21 - use super::email_account::{uses_oauth_imap, get_account_password, get_valid_access_token};
22 -
23 - // ============ IMAP Client Helper ============
24 -
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 - // ============ Types ============
77 -
78 - #[derive(Debug, Deserialize)]
79 - #[serde(rename_all = "camelCase")]
80 - pub struct EmailInput {
81 - pub project_id: Option<ProjectId>,
82 - pub from_address: String,
83 - pub to_address: String,
84 - pub subject: String,
85 - pub body: String,
86 - }
87 -
88 - #[derive(Debug, Deserialize)]
89 - #[serde(rename_all = "camelCase")]
90 - pub struct SendEmailInput {
91 - pub account_id: EmailAccountId,
92 - pub to_address: String,
93 - pub cc_address: Option<String>,
94 - pub bcc_address: Option<String>,
95 - pub subject: String,
96 - pub body: String,
97 - pub project_id: Option<ProjectId>,
98 - /// Message-ID of the email being replied to (sets In-Reply-To header).
99 - pub in_reply_to: Option<String>,
100 - /// Full References header chain for threading.
101 - pub references: Option<String>,
102 - /// Thread ID to join (from the original email's thread).
103 - pub thread_id: Option<String>,
104 - /// File paths to attach (from file picker dialog).
105 - #[serde(default)]
106 - pub attachment_paths: Vec<String>,
107 - }
108 -
109 - #[derive(Debug, Deserialize)]
110 - #[serde(rename_all = "camelCase")]
111 - pub struct DraftInput {
112 - /// If updating an existing draft, pass its ID.
113 - pub id: Option<EmailId>,
114 - pub account_id: Option<EmailAccountId>,
115 - pub to_address: Option<String>,
116 - pub cc_address: Option<String>,
117 - pub bcc_address: Option<String>,
118 - pub subject: Option<String>,
119 - pub body: Option<String>,
120 - pub in_reply_to: Option<String>,
121 - pub references: Option<String>,
122 - pub thread_id: Option<String>,
123 - }
124 -
125 - #[derive(Debug, Serialize)]
126 - #[serde(rename_all = "camelCase")]
127 - pub struct UnreadCountResponse {
128 - pub count: i64,
129 - }
130 -
131 - /// Email response with pre-computed fields for UI.
132 - #[derive(Debug, Serialize)]
133 - #[serde(rename_all = "camelCase")]
134 - pub struct EmailResponse {
135 - pub id: EmailId,
136 - pub project_id: Option<ProjectId>,
137 - pub project_name: Option<String>,
138 - pub from: String,
139 - pub to: String,
140 - pub subject: String,
141 - pub body: String,
142 - pub html_body: Option<String>,
143 - /// Char-safe ~100-char plaintext preview for the list view (Rust does the slice).
144 - pub body_preview: String,
145 - /// Whether the body was truncated at sync and can be re-fetched in full.
146 - pub body_truncated: bool,
147 - pub is_read: bool,
148 - pub is_archived: bool,
149 - pub received_at: chrono::DateTime<Utc>,
150 - pub message_id: Option<String>,
151 - pub in_reply_to: Option<String>,
152 - pub thread_id: Option<String>,
153 - pub email_account_id: Option<EmailAccountId>,
154 - pub is_outgoing: bool,
155 - pub snoozed_until: Option<chrono::DateTime<Utc>>,
156 - pub waiting_for_response: bool,
157 - pub waiting_since: Option<chrono::DateTime<Utc>>,
158 - pub expected_response_date: Option<chrono::DateTime<Utc>>,
159 - // Pre-computed fields
160 - pub is_snoozed: bool,
161 - pub is_waiting: bool,
162 - pub is_response_overdue: bool,
163 - pub received_formatted: String,
164 - /// Human-readable snooze time: "today", "tomorrow", "+3d", "Mar 15"
165 - pub snoozed_until_formatted: Option<String>,
166 - /// Parsed attachment metadata from IMAP sync (filename, size, mime_type, blob_hash).
167 - pub attachments: Vec<EmailAttachmentInfo>,
168 - /// IMAP source folder (INBOX, Sent, Archive, etc.).
169 - pub source_folder: Option<String>,
170 - /// Local labels/tags.
171 - pub labels: Vec<String>,
172 - /// Whether this email is a draft.
173 - pub is_draft: bool,
174 - /// CC recipients (drafts).
175 - pub cc_address: Option<String>,
176 - /// BCC recipients (drafts).
177 - pub bcc_address: Option<String>,
178 - /// Account ID to send from (drafts).
179 - pub draft_account_id: Option<EmailAccountId>,
180 - }
181 -
182 - /// Attachment info exposed to the frontend for display.
183 - #[derive(Debug, Serialize)]
184 - #[serde(rename_all = "camelCase")]
185 - pub struct EmailAttachmentInfo {
186 - pub filename: String,
187 - pub mime_type: String,
188 - pub size: usize,
189 - pub blob_hash: String,
190 - pub size_formatted: String,
191 - }
192 -
193 - impl From<Email> for EmailResponse {
194 - fn from(e: Email) -> Self {
195 - let is_snoozed = e.is_snoozed();
196 - let is_waiting = e.is_waiting();
197 - let is_response_overdue = e.is_response_overdue();
198 -
199 - let now = Utc::now();
200 - let snoozed_until_formatted = e.snoozed_until.map(|s| format_relative_future(s, now));
201 - let received_formatted = format_elapsed_time(e.received_at, now);
202 -
203 - // Char-boundary-safe preview (byte slicing would panic on multibyte content).
204 - let body_preview: String = e.body.chars().take(100).collect();
205 -
206 - let attachments = e.attachment_meta.as_deref()
207 - .and_then(|json| serde_json::from_str::<Vec<AttachmentMeta>>(json).ok())
208 - .unwrap_or_default()
209 - .into_iter()
210 - .map(|m| EmailAttachmentInfo {
211 - size_formatted: goingson_core::format_file_size(m.size as i64),
212 - filename: m.filename,
213 - mime_type: m.mime_type,
214 - size: m.size,
215 - blob_hash: m.blob_hash,
216 - })
217 - .collect();
218 -
219 - EmailResponse {
220 - id: e.id,
221 - project_id: e.project_id,
222 - project_name: e.project_name,
223 - from: e.from,
224 - to: e.to,
225 - subject: e.subject,
226 - body: e.body,
227 - html_body: e.html_body,
228 - body_preview,
229 - body_truncated: e.body_truncated,
230 - is_read: e.is_read,
231 - is_archived: e.is_archived,
232 - received_at: e.received_at,
233 - message_id: e.message_id,
234 - in_reply_to: e.in_reply_to,
235 - thread_id: e.thread_id,
236 - email_account_id: e.email_account_id,
237 - is_outgoing: e.is_outgoing,
238 - snoozed_until: e.snoozed_until,
239 - waiting_for_response: e.waiting_for_response,
240 - waiting_since: e.waiting_since,
241 - expected_response_date: e.expected_response_date,
242 - is_snoozed,
243 - is_waiting,
244 - is_response_overdue,
245 - received_formatted,
246 - snoozed_until_formatted,
247 - attachments,
248 - source_folder: e.source_folder,
249 - labels: e.labels,
250 - is_draft: e.is_draft,
251 - cc_address: e.cc_address,
252 - bcc_address: e.bcc_address,
253 - draft_account_id: e.draft_account_id,
254 - }
255 - }
256 - }
257 -
258 - /// A thread of emails, pre-grouped by the backend.
259 - #[derive(Debug, Serialize)]
260 - #[serde(rename_all = "camelCase")]
261 - pub struct EmailThreadResponse {
262 - pub thread_id: String,
263 - pub most_recent_email: EmailResponse,
264 - pub thread_count: usize,
265 - pub has_unread: bool,
266 - }
267 -
268 - /// Pagination parameters for email listing.
269 - #[derive(Debug, Default, Deserialize)]
270 - #[serde(rename_all = "camelCase")]
271 - pub struct EmailPaginationInput {
272 - #[serde(default)]
273 - pub include_archived: bool,
274 - pub offset: Option<i64>,
275 - pub limit: Option<i64>,
276 - /// Filter by source folder (e.g. "INBOX", "Sent", "Archive").
277 - pub folder: Option<String>,
278 - /// Filter by label.
279 - pub label: Option<String>,
280 - }
281 -
282 - /// Paginated response with total count for UI pagination.
283 - #[derive(Debug, Serialize)]
284 - #[serde(rename_all = "camelCase")]
285 - pub struct PaginatedEmailThreadsResponse {
286 - pub threads: Vec<EmailThreadResponse>,
287 - pub total: i64,
288 - }
289 -
290 - #[derive(Debug, Serialize)]
291 - #[serde(rename_all = "camelCase")]
292 - pub struct SendEmailResponse {
293 - pub success: bool,
294 - pub message_id: Option<String>,
295 - pub saved_email: EmailResponse,
296 - pub new_implicit_contacts: Vec<super::ContactResponse>,
297 - }
298 -
299 - // ============ Email Commands ============
300 -
301 - /// Lists all emails for the current user.
302 - #[tauri::command]
303 - #[instrument(skip_all)]
304 - pub async fn list_emails(state: State<'_, Arc<AppState>>, include_archived: Option<bool>) -> Result<Vec<EmailResponse>, ApiError> {
305 - // Body-less + capped: this flat list never renders a body (the reader fetches it
306 - // via get_by_id), so it must not materialize every body at once (Perf S3).
307 - let emails = state.emails.list_metadata(DESKTOP_USER_ID, include_archived.unwrap_or(false)).await?;
308 - Ok(emails.into_iter().map(EmailResponse::from).collect())
309 - }
310 -
311 - /// Lists emails grouped by thread with pagination.
312 - ///
313 - /// The repository groups emails by `thread_id` (set during sync — see
314 - /// `process_fetched_emails`), returns the most recent email per thread,
315 - /// and includes unread status and thread depth for the UI.
316 - #[tauri::command]
317 - #[instrument(skip_all)]
318 - pub async fn list_emails_threaded(
319 - state: State<'_, Arc<AppState>>,
320 - params: Option<EmailPaginationInput>,
321 - ) -> Result<PaginatedEmailThreadsResponse, ApiError> {
322 - let params = params.unwrap_or_default();
323 -
324 - let (threads, total) = state.emails
325 - .list_threaded(DESKTOP_USER_ID, params.include_archived, params.offset, params.limit, params.folder.as_deref(), params.label.as_deref())
326 - .await?;
327 -
328 - Ok(PaginatedEmailThreadsResponse {
329 - threads: threads.into_iter().map(|t| EmailThreadResponse {
330 - thread_id: t.thread_id,
331 - most_recent_email: EmailResponse::from(t.most_recent_email),
332 - thread_count: t.thread_count,
333 - has_unread: t.has_unread,
334 - }).collect(),
335 - total,
336 - })
337 - }
338 -
339 - /// Retrieves a single email by ID.
340 - #[tauri::command]
341 - #[instrument(skip_all)]
342 - pub async fn get_email(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<Option<EmailResponse>, ApiError> {
343 - let email = state.emails.get_by_id(id, DESKTOP_USER_ID).await?;
344 - Ok(email.map(EmailResponse::from))
345 - }
346 -
347 - /// Prefill for the compose window when replying or forwarding.
348 - ///
349 - /// All the domain logic (recipient assembly, `Re:`/`Fwd:` prefixing, quoting)
350 - /// is computed in Rust so the frontend just renders the result.
351 - #[derive(Debug, Serialize)]
352 - #[serde(rename_all = "camelCase")]
353 - pub struct ComposePrefillResponse {
354 - pub to: String,
355 - pub subject: String,
356 - pub body: String,
357 - pub in_reply_to: Option<String>,
358 - pub references: Option<String>,
359 - pub thread_id: Option<String>,
360 - /// Account to send from ("" when the source email has no account).
361 - pub account_id: String,
362 - }
363 -
364 - /// Format an email timestamp for a reply/forward attribution line, in local time.
365 - fn format_attribution_date(dt: chrono::DateTime<Utc>) -> String {
366 - dt.with_timezone(&chrono::Local)
367 - .format("%b %-d, %Y, %-I:%M %p")
368 - .to_string()
369 - }
370 -
371 - /// Builds the compose prefill for replying to an email.
372 - ///
373 - /// Reply-all assembles sender + original recipients, excluding the user's own
374 - /// account addresses (authoritative, from the account repo — not a browser
375 - /// cache) and de-duplicating.
376 - #[tauri::command]
377 - #[instrument(skip_all)]
378 - pub async fn build_reply_prefill(
379 - state: State<'_, Arc<AppState>>,
380 - id: EmailId,
381 - reply_all: bool,
382 - ) -> Result<ComposePrefillResponse, ApiError> {
383 - let email = state.emails.get_by_id(id, DESKTOP_USER_ID).await?
384 - .or_not_found("email", id)?;
385 -
386 - let own_addresses: Vec<String> = state.email_accounts.list_by_user(DESKTOP_USER_ID).await?
387 - .into_iter()
388 - .map(|a| a.email_address)
389 - .collect();
390 -
391 - let to = goingson_core::reply_recipients(&email.from, &email.to, &own_addresses, reply_all);
392 - let subject = goingson_core::reply_subject(&email.subject);
393 - let date = format_attribution_date(email.received_at);
394 - let body = goingson_core::quoted_reply_body(&email.from, &date, &email.body);
395 -
396 - Ok(ComposePrefillResponse {
397 - to,
398 - subject,
399 - body,
400 - in_reply_to: email.message_id.clone(),
401 - references: email.message_id,
402 - thread_id: email.thread_id,
403 - account_id: email.email_account_id.map(|a| a.to_string()).unwrap_or_default(),
404 - })
405 - }
406 -
407 - /// Builds the compose prefill for forwarding an email.
408 - #[tauri::command]
409 - #[instrument(skip_all)]
410 - pub async fn build_forward_prefill(
411 - state: State<'_, Arc<AppState>>,
412 - id: EmailId,
413 - ) -> Result<ComposePrefillResponse, ApiError> {
414 - let email = state.emails.get_by_id(id, DESKTOP_USER_ID).await?
415 - .or_not_found("email", id)?;
416 -
417 - let subject = goingson_core::forward_subject(&email.subject);
418 - let date = format_attribution_date(email.received_at);
419 - let body = goingson_core::forward_body(&email.from, &date, &email.subject, &email.to, &email.body);
420 -
421 - Ok(ComposePrefillResponse {
422 - to: String::new(),
423 - subject,
424 - body,
425 - in_reply_to: None,
426 - references: None,
427 - thread_id: None,
428 - account_id: email.email_account_id.map(|a| a.to_string()).unwrap_or_default(),
429 - })
430 - }
431 -
432 - /// Fetches the full body of a JMAP email whose body was truncated at sync
433 - /// (>100KB). Re-fetches via the provider, stores the full body, clears the
434 - /// truncation flag, and returns the body. If the email was not truncated, the
435 - /// already-stored body is returned unchanged.
436 - #[tauri::command]
437 - #[instrument(skip_all)]
438 - pub async fn fetch_email_full_body(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<String, ApiError> {
439 - let email = state.emails.get_by_id(id, DESKTOP_USER_ID).await?
440 - .or_not_found("email", id)?;
441 -
442 - if !email.body_truncated {
443 - return Ok(email.body);
444 - }
445 -
446 - let jmap_id = email.jmap_id
447 - .or_api_err(|| ApiError::bad_request("Email has no JMAP id; full body unavailable"))?;
448 - let account_id = email.email_account_id
449 - .or_api_err(|| ApiError::bad_request("Email has no account; cannot re-fetch body"))?;
450 - let account = state.email_accounts.get_by_id(account_id, DESKTOP_USER_ID).await?
451 - .or_not_found("emailAccount", account_id)?;
452 -
453 - let mut client = super::email_sync::build_jmap_client(state.inner(), &account).await?;
454 - let body = client.fetch_email_full_body(&jmap_id).await
455 - .map_api_err("Failed to fetch full email body", ApiError::external_service)?;
456 -
457 - state.emails.set_full_body(id, DESKTOP_USER_ID, &body).await?;
458 - Ok(body)
459 - }
460 -
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"),
Lines truncated
@@ -0,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 + }
@@ -0,0 +1,751 @@
1 + //! Email management commands.
2 + //!
3 + //! Provides CRUD operations and status management for emails.
4 + //! Email account management is in `email_account.rs`;
5 + //! sync operations are in `email_sync.rs`.
6 +
7 + use chrono::{Utc};
8 + use serde::{Deserialize, Serialize};
9 + use std::sync::Arc;
10 + use tauri::State;
11 +
12 + use tracing::{instrument, warn};
13 +
14 + use goingson_core::{AttachmentMeta, Email, EmailAccountId, EmailId, NewEmail, NewEmailWithTracking, ProjectId};
15 +
16 + use crate::email::{uses_jmap, ImapClient, SmtpClient};
17 + use crate::state::{AppState, DESKTOP_USER_ID};
18 + use goingson_db_sqlite::utils::is_valid_email;
19 + use goingson_core::date_utils::{format_relative_future, format_elapsed_time};
20 + use super::{ApiError, LinkProjectInput, OptionApiError, OptionNotFound, ResultApiError, SnoozeInput, WaitingInput};
21 + use super::email_account::{uses_oauth_imap, get_account_password, get_valid_access_token};
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 +
30 + // ============ IMAP Client Helper ============
31 +
32 + // ============ Types ============
33 +
34 + #[derive(Debug, Deserialize)]
35 + #[serde(rename_all = "camelCase")]
36 + pub struct EmailInput {
37 + pub project_id: Option<ProjectId>,
38 + pub from_address: String,
39 + pub to_address: String,
40 + pub subject: String,
41 + pub body: String,
42 + }
43 +
44 + #[derive(Debug, Deserialize)]
45 + #[serde(rename_all = "camelCase")]
46 + pub struct SendEmailInput {
47 + pub account_id: EmailAccountId,
48 + pub to_address: String,
49 + pub cc_address: Option<String>,
50 + pub bcc_address: Option<String>,
51 + pub subject: String,
52 + pub body: String,
53 + pub project_id: Option<ProjectId>,
54 + /// Message-ID of the email being replied to (sets In-Reply-To header).
55 + pub in_reply_to: Option<String>,
56 + /// Full References header chain for threading.
57 + pub references: Option<String>,
58 + /// Thread ID to join (from the original email's thread).
59 + pub thread_id: Option<String>,
60 + /// File paths to attach (from file picker dialog).
61 + #[serde(default)]
62 + pub attachment_paths: Vec<String>,
63 + }
64 +
65 + #[derive(Debug, Deserialize)]
66 + #[serde(rename_all = "camelCase")]
67 + pub struct DraftInput {
68 + /// If updating an existing draft, pass its ID.
69 + pub id: Option<EmailId>,
70 + pub account_id: Option<EmailAccountId>,
71 + pub to_address: Option<String>,
72 + pub cc_address: Option<String>,
73 + pub bcc_address: Option<String>,
74 + pub subject: Option<String>,
75 + pub body: Option<String>,
76 + pub in_reply_to: Option<String>,
77 + pub references: Option<String>,
78 + pub thread_id: Option<String>,
79 + }
80 +
81 + #[derive(Debug, Serialize)]
82 + #[serde(rename_all = "camelCase")]
83 + pub struct UnreadCountResponse {
84 + pub count: i64,
85 + }
86 +
87 + /// Email response with pre-computed fields for UI.
88 + #[derive(Debug, Serialize)]
89 + #[serde(rename_all = "camelCase")]
90 + pub struct EmailResponse {
91 + pub id: EmailId,
92 + pub project_id: Option<ProjectId>,
93 + pub project_name: Option<String>,
94 + pub from: String,
95 + pub to: String,
96 + pub subject: String,
97 + pub body: String,
98 + pub html_body: Option<String>,
99 + /// Char-safe ~100-char plaintext preview for the list view (Rust does the slice).
100 + pub body_preview: String,
101 + /// Whether the body was truncated at sync and can be re-fetched in full.
102 + pub body_truncated: bool,
103 + pub is_read: bool,
104 + pub is_archived: bool,
105 + pub received_at: chrono::DateTime<Utc>,
106 + pub message_id: Option<String>,
107 + pub in_reply_to: Option<String>,
108 + pub thread_id: Option<String>,
109 + pub email_account_id: Option<EmailAccountId>,
110 + pub is_outgoing: bool,
111 + pub snoozed_until: Option<chrono::DateTime<Utc>>,
112 + pub waiting_for_response: bool,
113 + pub waiting_since: Option<chrono::DateTime<Utc>>,
114 + pub expected_response_date: Option<chrono::DateTime<Utc>>,
115 + // Pre-computed fields
116 + pub is_snoozed: bool,
117 + pub is_waiting: bool,
118 + pub is_response_overdue: bool,
119 + pub received_formatted: String,
120 + /// Human-readable snooze time: "today", "tomorrow", "+3d", "Mar 15"
121 + pub snoozed_until_formatted: Option<String>,
122 + /// Parsed attachment metadata from IMAP sync (filename, size, mime_type, blob_hash).
123 + pub attachments: Vec<EmailAttachmentInfo>,
124 + /// IMAP source folder (INBOX, Sent, Archive, etc.).
125 + pub source_folder: Option<String>,
126 + /// Local labels/tags.
127 + pub labels: Vec<String>,
128 + /// Whether this email is a draft.
129 + pub is_draft: bool,
130 + /// CC recipients (drafts).
131 + pub cc_address: Option<String>,
132 + /// BCC recipients (drafts).
133 + pub bcc_address: Option<String>,
134 + /// Account ID to send from (drafts).
135 + pub draft_account_id: Option<EmailAccountId>,
136 + }
137 +
138 + /// Attachment info exposed to the frontend for display.
139 + #[derive(Debug, Serialize)]
140 + #[serde(rename_all = "camelCase")]
141 + pub struct EmailAttachmentInfo {
142 + pub filename: String,
143 + pub mime_type: String,
144 + pub size: usize,
145 + pub blob_hash: String,
146 + pub size_formatted: String,
147 + }
148 +
149 + impl From<Email> for EmailResponse {
150 + fn from(e: Email) -> Self {
151 + let is_snoozed = e.is_snoozed();
152 + let is_waiting = e.is_waiting();
153 + let is_response_overdue = e.is_response_overdue();
154 +
155 + let now = Utc::now();
156 + let snoozed_until_formatted = e.snoozed_until.map(|s| format_relative_future(s, now));
157 + let received_formatted = format_elapsed_time(e.received_at, now);
158 +
159 + // Char-boundary-safe preview (byte slicing would panic on multibyte content).
160 + let body_preview: String = e.body.chars().take(100).collect();
161 +
162 + let attachments = e.attachment_meta.as_deref()
163 + .and_then(|json| serde_json::from_str::<Vec<AttachmentMeta>>(json).ok())
164 + .unwrap_or_default()
165 + .into_iter()
166 + .map(|m| EmailAttachmentInfo {
167 + size_formatted: goingson_core::format_file_size(m.size as i64),
168 + filename: m.filename,
169 + mime_type: m.mime_type,
170 + size: m.size,
171 + blob_hash: m.blob_hash,
172 + })
173 + .collect();
174 +
175 + EmailResponse {
176 + id: e.id,
177 + project_id: e.project_id,
178 + project_name: e.project_name,
179 + from: e.from,
180 + to: e.to,
181 + subject: e.subject,
182 + body: e.body,
183 + html_body: e.html_body,
184 + body_preview,
185 + body_truncated: e.body_truncated,
186 + is_read: e.is_read,
187 + is_archived: e.is_archived,
188 + received_at: e.received_at,
189 + message_id: e.message_id,
190 + in_reply_to: e.in_reply_to,
191 + thread_id: e.thread_id,
192 + email_account_id: e.email_account_id,
193 + is_outgoing: e.is_outgoing,
194 + snoozed_until: e.snoozed_until,
195 + waiting_for_response: e.waiting_for_response,
196 + waiting_since: e.waiting_since,
197 + expected_response_date: e.expected_response_date,
198 + is_snoozed,
199 + is_waiting,
200 + is_response_overdue,
201 + received_formatted,
202 + snoozed_until_formatted,
203 + attachments,
204 + source_folder: e.source_folder,
205 + labels: e.labels,
206 + is_draft: e.is_draft,
207 + cc_address: e.cc_address,
208 + bcc_address: e.bcc_address,
209 + draft_account_id: e.draft_account_id,
210 + }
211 + }
212 + }
213 +
214 + /// A thread of emails, pre-grouped by the backend.
215 + #[derive(Debug, Serialize)]
216 + #[serde(rename_all = "camelCase")]
217 + pub struct EmailThreadResponse {
218 + pub thread_id: String,
219 + pub most_recent_email: EmailResponse,
220 + pub thread_count: usize,
221 + pub has_unread: bool,
222 + }
223 +
224 + /// Pagination parameters for email listing.
225 + #[derive(Debug, Default, Deserialize)]
226 + #[serde(rename_all = "camelCase")]
227 + pub struct EmailPaginationInput {
228 + #[serde(default)]
229 + pub include_archived: bool,
230 + pub offset: Option<i64>,
231 + pub limit: Option<i64>,
232 + /// Filter by source folder (e.g. "INBOX", "Sent", "Archive").
233 + pub folder: Option<String>,
234 + /// Filter by label.
235 + pub label: Option<String>,
236 + }
237 +
238 + /// Paginated response with total count for UI pagination.
239 + #[derive(Debug, Serialize)]
240 + #[serde(rename_all = "camelCase")]
241 + pub struct PaginatedEmailThreadsResponse {
242 + pub threads: Vec<EmailThreadResponse>,
243 + pub total: i64,
244 + }
245 +
246 + #[derive(Debug, Serialize)]
247 + #[serde(rename_all = "camelCase")]
248 + pub struct SendEmailResponse {
249 + pub success: bool,
250 + pub message_id: Option<String>,
251 + pub saved_email: EmailResponse,
252 + pub new_implicit_contacts: Vec<super::ContactResponse>,
253 + }
254 +
255 + // ============ Email Commands ============
256 +
257 + /// Lists all emails for the current user.
258 + #[tauri::command]
259 + #[instrument(skip_all)]
260 + pub async fn list_emails(state: State<'_, Arc<AppState>>, include_archived: Option<bool>) -> Result<Vec<EmailResponse>, ApiError> {
261 + // Body-less + capped: this flat list never renders a body (the reader fetches it
262 + // via get_by_id), so it must not materialize every body at once (Perf S3).
263 + let emails = state.emails.list_metadata(DESKTOP_USER_ID, include_archived.unwrap_or(false)).await?;
264 + Ok(emails.into_iter().map(EmailResponse::from).collect())
265 + }
266 +
267 + /// Lists emails grouped by thread with pagination.
268 + ///
269 + /// The repository groups emails by `thread_id` (set during sync — see
270 + /// `process_fetched_emails`), returns the most recent email per thread,
271 + /// and includes unread status and thread depth for the UI.
272 + #[tauri::command]
273 + #[instrument(skip_all)]
274 + pub async fn list_emails_threaded(
275 + state: State<'_, Arc<AppState>>,
276 + params: Option<EmailPaginationInput>,
277 + ) -> Result<PaginatedEmailThreadsResponse, ApiError> {
278 + let params = params.unwrap_or_default();
279 +
280 + let (threads, total) = state.emails
281 + .list_threaded(DESKTOP_USER_ID, params.include_archived, params.offset, params.limit, params.folder.as_deref(), params.label.as_deref())
282 + .await?;
283 +
284 + Ok(PaginatedEmailThreadsResponse {
285 + threads: threads.into_iter().map(|t| EmailThreadResponse {
286 + thread_id: t.thread_id,
287 + most_recent_email: EmailResponse::from(t.most_recent_email),
288 + thread_count: t.thread_count,
289 + has_unread: t.has_unread,
290 + }).collect(),
291 + total,
292 + })
293 + }
294 +
295 + /// Retrieves a single email by ID.
296 + #[tauri::command]
297 + #[instrument(skip_all)]
298 + pub async fn get_email(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<Option<EmailResponse>, ApiError> {
299 + let email = state.emails.get_by_id(id, DESKTOP_USER_ID).await?;
300 + Ok(email.map(EmailResponse::from))
301 + }
302 +
303 + /// Prefill for the compose window when replying or forwarding.
304 + ///
305 + /// All the domain logic (recipient assembly, `Re:`/`Fwd:` prefixing, quoting)
306 + /// is computed in Rust so the frontend just renders the result.
307 + #[derive(Debug, Serialize)]
308 + #[serde(rename_all = "camelCase")]
309 + pub struct ComposePrefillResponse {
310 + pub to: String,
311 + pub subject: String,
312 + pub body: String,
313 + pub in_reply_to: Option<String>,
314 + pub references: Option<String>,
315 + pub thread_id: Option<String>,
316 + /// Account to send from ("" when the source email has no account).
317 + pub account_id: String,
318 + }
319 +
320 + /// Format an email timestamp for a reply/forward attribution line, in local time.
321 + fn format_attribution_date(dt: chrono::DateTime<Utc>) -> String {
322 + dt.with_timezone(&chrono::Local)
323 + .format("%b %-d, %Y, %-I:%M %p")
324 + .to_string()
325 + }
326 +
327 + /// Builds the compose prefill for replying to an email.
328 + ///
329 + /// Reply-all assembles sender + original recipients, excluding the user's own
330 + /// account addresses (authoritative, from the account repo — not a browser
331 + /// cache) and de-duplicating.
332 + #[tauri::command]
333 + #[instrument(skip_all)]
334 + pub async fn build_reply_prefill(
335 + state: State<'_, Arc<AppState>>,
336 + id: EmailId,
337 + reply_all: bool,
338 + ) -> Result<ComposePrefillResponse, ApiError> {
339 + let email = state.emails.get_by_id(id, DESKTOP_USER_ID).await?
340 + .or_not_found("email", id)?;
341 +
342 + let own_addresses: Vec<String> = state.email_accounts.list_by_user(DESKTOP_USER_ID).await?
343 + .into_iter()
344 + .map(|a| a.email_address)
345 + .collect();
346 +
347 + let to = goingson_core::reply_recipients(&email.from, &email.to, &own_addresses, reply_all);
348 + let subject = goingson_core::reply_subject(&email.subject);
349 + let date = format_attribution_date(email.received_at);
350 + let body = goingson_core::quoted_reply_body(&email.from, &date, &email.body);
351 +
352 + Ok(ComposePrefillResponse {
353 + to,
354 + subject,
355 + body,
356 + in_reply_to: email.message_id.clone(),
357 + references: email.message_id,
358 + thread_id: email.thread_id,
359 + account_id: email.email_account_id.map(|a| a.to_string()).unwrap_or_default(),
360 + })
361 + }
362 +
363 + /// Builds the compose prefill for forwarding an email.
364 + #[tauri::command]
365 + #[instrument(skip_all)]
366 + pub async fn build_forward_prefill(
367 + state: State<'_, Arc<AppState>>,
368 + id: EmailId,
369 + ) -> Result<ComposePrefillResponse, ApiError> {
370 + let email = state.emails.get_by_id(id, DESKTOP_USER_ID).await?
371 + .or_not_found("email", id)?;
372 +
373 + let subject = goingson_core::forward_subject(&email.subject);
374 + let date = format_attribution_date(email.received_at);
375 + let body = goingson_core::forward_body(&email.from, &date, &email.subject, &email.to, &email.body);
376 +
377 + Ok(ComposePrefillResponse {
378 + to: String::new(),
379 + subject,
380 + body,
381 + in_reply_to: None,
382 + references: None,
383 + thread_id: None,
384 + account_id: email.email_account_id.map(|a| a.to_string()).unwrap_or_default(),
385 + })
386 + }
387 +
388 + /// Fetches the full body of a JMAP email whose body was truncated at sync
389 + /// (>100KB). Re-fetches via the provider, stores the full body, clears the
390 + /// truncation flag, and returns the body. If the email was not truncated, the
391 + /// already-stored body is returned unchanged.
392 + #[tauri::command]
393 + #[instrument(skip_all)]
394 + pub async fn fetch_email_full_body(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<String, ApiError> {
395 + let email = state.emails.get_by_id(id, DESKTOP_USER_ID).await?
396 + .or_not_found("email", id)?;
397 +
398 + if !email.body_truncated {
399 + return Ok(email.body);
400 + }
401 +
402 + let jmap_id = email.jmap_id
403 + .or_api_err(|| ApiError::bad_request("Email has no JMAP id; full body unavailable"))?;
404 + let account_id = email.email_account_id
405 + .or_api_err(|| ApiError::bad_request("Email has no account; cannot re-fetch body"))?;
406 + let account = state.email_accounts.get_by_id(account_id, DESKTOP_USER_ID).await?
407 + .or_not_found("emailAccount", account_id)?;
408 +
409 + let mut client = super::email_sync::build_jmap_client(state.inner(), &account).await?;
410 + let body = client.fetch_email_full_body(&jmap_id).await
411 + .map_api_err("Failed to fetch full email body", ApiError::external_service)?;
412 +
413 + state.emails.set_full_body(id, DESKTOP_USER_ID, &body).await?;
414 + Ok(body)
415 + }
416 +
417 + /// Creates a new email record (draft or imported).
418 + #[tauri::command]
419 + #[instrument(skip_all)]
420 + pub async fn create_email(state: State<'_, Arc<AppState>>, input: EmailInput) -> Result<EmailResponse, ApiError> {
421 + if input.subject.trim().is_empty() {
422 + return Err(ApiError::validation("subject", "Subject is required"));
423 + }
424 +
425 + if !is_valid_email(&input.from_address) {
426 + return Err(ApiError::validation("fromAddress", "Invalid 'from' email address"));
427 + }
428 + if !is_valid_email(&input.to_address) {
429 + return Err(ApiError::validation("toAddress", "Invalid 'to' email address"));
430 + }
431 +
432 + let new_email = NewEmail {
433 + project_id: input.project_id,
434 + from_address: input.from_address,
435 + to_address: input.to_address,
436 + subject: input.subject,
437 + body: input.body,
438 + is_read: false,
439 + received_at: None,
440 + };
441 +
442 + let email = state.emails.create(DESKTOP_USER_ID, new_email).await?;
443 + Ok(EmailResponse::from(email))
444 + }
445 +
446 + /// Save an email draft (create or update).
447 + #[tauri::command]
448 + #[instrument(skip_all)]
449 + pub async fn save_email_draft(state: State<'_, Arc<AppState>>, input: DraftInput) -> Result<EmailResponse, ApiError> {
450 + let id = input.id.unwrap_or_default();
451 + let account_id = input.account_id;
452 + let from = if let Some(aid) = account_id {
453 + let acct = state.email_accounts.get_by_id(aid, DESKTOP_USER_ID).await?;
454 + acct.map(|a| a.email_address).unwrap_or_default()
455 + } else {
456 + String::new()
457 + };
458 +
459 + let email = state.emails.save_draft(
460 + id, DESKTOP_USER_ID,
461 + &from,
462 + &input.to_address.unwrap_or_default(),
463 + input.cc_address.as_deref(),
464 + input.bcc_address.as_deref(),
465 + &input.subject.unwrap_or_default(),
466 + &input.body.unwrap_or_default(),
467 + account_id,
468 + input.in_reply_to.as_deref(),
469 + input.references.as_deref(),
470 + input.thread_id.as_deref(),
471 + ).await?;
472 +
473 + Ok(EmailResponse::from(email))
474 + }
475 +
476 + /// List all draft emails.
477 + #[tauri::command]
478 + #[instrument(skip_all)]
479 + pub async fn list_email_drafts(state: State<'_, Arc<AppState>>) -> Result<Vec<EmailResponse>, ApiError> {
480 + let drafts = state.emails.list_drafts(DESKTOP_USER_ID).await?;
481 + Ok(drafts.into_iter().map(EmailResponse::from).collect())
482 + }
483 +
484 + /// Send a draft: send via SMTP, delete the draft record, save as sent.
485 + #[tauri::command]
486 + #[instrument(skip_all)]
487 + pub async fn send_email_draft(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<SendEmailResponse, ApiError> {
488 + let draft = state.emails.get_by_id(id, DESKTOP_USER_ID).await?
489 + .or_not_found("draft", id)?;
490 +
491 + if !draft.is_draft {
492 + return Err(ApiError::bad_request("Email is not a draft"));
493 + }
494 +
495 + let account_id = draft.draft_account_id
496 + .ok_or_else(|| ApiError::validation_msg("Draft has no account selected"))?;
497 +
498 + // Build send input from draft fields
499 + let input = SendEmailInput {
500 + account_id,
Lines truncated
@@ -0,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 + }
@@ -0,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 + }