Skip to main content

max / goingson

35.3 KB · 1008 lines History Blame Raw
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, 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 pub is_read: bool,
144 pub is_archived: bool,
145 pub received_at: chrono::DateTime<Utc>,
146 pub message_id: Option<String>,
147 pub in_reply_to: Option<String>,
148 pub thread_id: Option<String>,
149 pub email_account_id: Option<EmailAccountId>,
150 pub is_outgoing: bool,
151 pub snoozed_until: Option<chrono::DateTime<Utc>>,
152 pub waiting_for_response: bool,
153 pub waiting_since: Option<chrono::DateTime<Utc>>,
154 pub expected_response_date: Option<chrono::DateTime<Utc>>,
155 // Pre-computed fields
156 pub is_snoozed: bool,
157 pub is_waiting: bool,
158 pub is_response_overdue: bool,
159 pub received_formatted: String,
160 /// Human-readable snooze time: "today", "tomorrow", "+3d", "Mar 15"
161 pub snoozed_until_formatted: Option<String>,
162 /// Parsed attachment metadata from IMAP sync (filename, size, mime_type, blob_hash).
163 pub attachments: Vec<EmailAttachmentInfo>,
164 /// IMAP source folder (INBOX, Sent, Archive, etc.).
165 pub source_folder: Option<String>,
166 /// Local labels/tags.
167 pub labels: Vec<String>,
168 /// Whether this email is a draft.
169 pub is_draft: bool,
170 /// CC recipients (drafts).
171 pub cc_address: Option<String>,
172 /// BCC recipients (drafts).
173 pub bcc_address: Option<String>,
174 /// Account ID to send from (drafts).
175 pub draft_account_id: Option<EmailAccountId>,
176 }
177
178 /// Attachment info exposed to the frontend for display.
179 #[derive(Debug, Serialize)]
180 #[serde(rename_all = "camelCase")]
181 pub struct EmailAttachmentInfo {
182 pub filename: String,
183 pub mime_type: String,
184 pub size: usize,
185 pub blob_hash: String,
186 pub size_formatted: String,
187 }
188
189 impl From<Email> for EmailResponse {
190 fn from(e: Email) -> Self {
191 let is_snoozed = e.is_snoozed();
192 let is_waiting = e.is_waiting();
193 let is_response_overdue = e.is_response_overdue();
194
195 let now = Utc::now();
196 let snoozed_until_formatted = e.snoozed_until.map(|s| format_relative_future(s, now));
197 let received_formatted = format_elapsed_time(e.received_at, now);
198
199 let attachments = e.attachment_meta.as_deref()
200 .and_then(|json| serde_json::from_str::<Vec<AttachmentMeta>>(json).ok())
201 .unwrap_or_default()
202 .into_iter()
203 .map(|m| EmailAttachmentInfo {
204 size_formatted: goingson_core::format_file_size(m.size as i64),
205 filename: m.filename,
206 mime_type: m.mime_type,
207 size: m.size,
208 blob_hash: m.blob_hash,
209 })
210 .collect();
211
212 EmailResponse {
213 id: e.id,
214 project_id: e.project_id,
215 project_name: e.project_name,
216 from: e.from,
217 to: e.to,
218 subject: e.subject,
219 body: e.body,
220 html_body: e.html_body,
221 is_read: e.is_read,
222 is_archived: e.is_archived,
223 received_at: e.received_at,
224 message_id: e.message_id,
225 in_reply_to: e.in_reply_to,
226 thread_id: e.thread_id,
227 email_account_id: e.email_account_id,
228 is_outgoing: e.is_outgoing,
229 snoozed_until: e.snoozed_until,
230 waiting_for_response: e.waiting_for_response,
231 waiting_since: e.waiting_since,
232 expected_response_date: e.expected_response_date,
233 is_snoozed,
234 is_waiting,
235 is_response_overdue,
236 received_formatted,
237 snoozed_until_formatted,
238 attachments,
239 source_folder: e.source_folder,
240 labels: e.labels,
241 is_draft: e.is_draft,
242 cc_address: e.cc_address,
243 bcc_address: e.bcc_address,
244 draft_account_id: e.draft_account_id,
245 }
246 }
247 }
248
249 /// A thread of emails, pre-grouped by the backend.
250 #[derive(Debug, Serialize)]
251 #[serde(rename_all = "camelCase")]
252 pub struct EmailThreadResponse {
253 pub thread_id: String,
254 pub most_recent_email: EmailResponse,
255 pub thread_count: usize,
256 pub has_unread: bool,
257 }
258
259 /// Pagination parameters for email listing.
260 #[derive(Debug, Default, Deserialize)]
261 #[serde(rename_all = "camelCase")]
262 pub struct EmailPaginationInput {
263 #[serde(default)]
264 pub include_archived: bool,
265 pub offset: Option<i64>,
266 pub limit: Option<i64>,
267 /// Filter by source folder (e.g. "INBOX", "Sent", "Archive").
268 pub folder: Option<String>,
269 /// Filter by label.
270 pub label: Option<String>,
271 }
272
273 /// Paginated response with total count for UI pagination.
274 #[derive(Debug, Serialize)]
275 #[serde(rename_all = "camelCase")]
276 pub struct PaginatedEmailThreadsResponse {
277 pub threads: Vec<EmailThreadResponse>,
278 pub total: i64,
279 }
280
281 #[derive(Debug, Serialize)]
282 #[serde(rename_all = "camelCase")]
283 pub struct SendEmailResponse {
284 pub success: bool,
285 pub message_id: Option<String>,
286 pub saved_email: EmailResponse,
287 pub new_implicit_contacts: Vec<super::ContactResponse>,
288 }
289
290 // ============ Email Commands ============
291
292 /// Lists all emails for the current user.
293 #[tauri::command]
294 #[instrument(skip_all)]
295 pub async fn list_emails(state: State<'_, Arc<AppState>>, include_archived: Option<bool>) -> Result<Vec<EmailResponse>, ApiError> {
296 let emails = state.emails.list_all(DESKTOP_USER_ID, include_archived.unwrap_or(false)).await?;
297 Ok(emails.into_iter().map(EmailResponse::from).collect())
298 }
299
300 /// Lists emails grouped by thread with pagination.
301 ///
302 /// The repository groups emails by `thread_id` (set during sync — see
303 /// `process_fetched_emails`), returns the most recent email per thread,
304 /// and includes unread status and thread depth for the UI.
305 #[tauri::command]
306 #[instrument(skip_all)]
307 pub async fn list_emails_threaded(
308 state: State<'_, Arc<AppState>>,
309 params: Option<EmailPaginationInput>,
310 ) -> Result<PaginatedEmailThreadsResponse, ApiError> {
311 let params = params.unwrap_or_default();
312
313 let (threads, total) = state.emails
314 .list_threaded(DESKTOP_USER_ID, params.include_archived, params.offset, params.limit, params.folder.as_deref(), params.label.as_deref())
315 .await?;
316
317 Ok(PaginatedEmailThreadsResponse {
318 threads: threads.into_iter().map(|t| EmailThreadResponse {
319 thread_id: t.thread_id,
320 most_recent_email: EmailResponse::from(t.most_recent_email),
321 thread_count: t.thread_count,
322 has_unread: t.has_unread,
323 }).collect(),
324 total,
325 })
326 }
327
328 /// Retrieves a single email by ID.
329 #[tauri::command]
330 #[instrument(skip_all)]
331 pub async fn get_email(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<Option<EmailResponse>, ApiError> {
332 let email = state.emails.get_by_id(id, DESKTOP_USER_ID).await?;
333 Ok(email.map(EmailResponse::from))
334 }
335
336 /// Opens an email in the system's default web browser.
337 #[tauri::command]
338 #[instrument(skip_all)]
339 pub async fn open_email_in_browser(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<(), ApiError> {
340 let email = state.emails.get_by_id(id, DESKTOP_USER_ID).await?
341 .or_not_found("email", id)?;
342
343 let html_content = if let Some(ref html) = email.html_body {
344 let sanitized_body = docengine::sanitize_html(html);
345 format!(
346 r#"<!DOCTYPE html>
347 <html>
348 <head>
349 <meta charset="utf-8">
350 <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src https: data:;">
351 <title>{}</title>
352 <style>
353 body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 2rem; }}
354 .email-header {{ border-bottom: 1px solid #ccc; padding-bottom: 1rem; margin-bottom: 1rem; }}
355 .email-header p {{ margin: 0.25rem 0; }}
356 .email-label {{ font-weight: bold; color: #666; }}
357 </style>
358 </head>
359 <body>
360 <div class="email-header">
361 <p><span class="email-label">From:</span> {}</p>
362 <p><span class="email-label">To:</span> {}</p>
363 <p><span class="email-label">Subject:</span> {}</p>
364 <p><span class="email-label">Date:</span> {}</p>
365 </div>
366 <div class="email-body">
367 {}
368 </div>
369 </body>
370 </html>"#,
371 html_escape(&email.subject),
372 html_escape(&email.from),
373 html_escape(&email.to),
374 html_escape(&email.subject),
375 email.received_at.format("%Y-%m-%d %H:%M:%S UTC"),
376 sanitized_body
377 )
378 } else {
379 let body_html = html_escape(&email.body).replace('\n', "<br>\n");
380 format!(
381 r#"<!DOCTYPE html>
382 <html>
383 <head>
384 <meta charset="utf-8">
385 <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src https: data:;">
386 <title>{}</title>
387 <style>
388 body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 2rem; line-height: 1.6; }}
389 .email-header {{ border-bottom: 1px solid #ccc; padding-bottom: 1rem; margin-bottom: 1rem; }}
390 .email-header p {{ margin: 0.25rem 0; }}
391 .email-label {{ font-weight: bold; color: #666; }}
392 .email-body {{ white-space: pre-wrap; }}
393 </style>
394 </head>
395 <body>
396 <div class="email-header">
397 <p><span class="email-label">From:</span> {}</p>
398 <p><span class="email-label">To:</span> {}</p>
399 <p><span class="email-label">Subject:</span> {}</p>
400 <p><span class="email-label">Date:</span> {}</p>
401 </div>
402 <div class="email-body">{}</div>
403 </body>
404 </html>"#,
405 html_escape(&email.subject),
406 html_escape(&email.from),
407 html_escape(&email.to),
408 html_escape(&email.subject),
409 email.received_at.format("%Y-%m-%d %H:%M:%S UTC"),
410 body_html
411 )
412 };
413
414 let temp_dir = std::env::temp_dir();
415 let file_name = format!("goingson_email_{}_{}.html", id, uuid::Uuid::new_v4().simple());
416 let file_path = temp_dir.join(file_name);
417
418 tokio::fs::write(&file_path, html_content).await
419 .map_api_err("Failed to write temp file", ApiError::internal)?;
420
421 let path = file_path.clone();
422 tokio::task::spawn_blocking(move || open::that(&path)).await
423 .map_api_err("Task join error", ApiError::internal)?
424 .map_api_err("Failed to open browser", ApiError::internal)?;
425
426 // Clean up temp file after a delay to give the browser time to load it
427 tokio::spawn(async move {
428 tokio::time::sleep(std::time::Duration::from_secs(30)).await;
429 let _ = tokio::fs::remove_file(&file_path).await;
430 });
431
432 Ok(())
433 }
434
435 /// Remove stale `goingson_email_*.html` temp files from previous sessions.
436 pub async fn cleanup_stale_temp_files() {
437 let temp_dir = std::env::temp_dir();
438 let mut entries = match tokio::fs::read_dir(&temp_dir).await {
439 Ok(e) => e,
440 Err(_) => return,
441 };
442 while let Ok(Some(entry)) = entries.next_entry().await {
443 if let Some(name) = entry.file_name().to_str() {
444 if name.starts_with("goingson_email_") && name.ends_with(".html") {
445 let _ = tokio::fs::remove_file(entry.path()).await;
446 }
447 }
448 }
449 }
450
451 /// Escape HTML special characters to prevent XSS when injecting user
452 /// content (email bodies, subjects) into the browser preview template.
453 fn html_escape(s: &str) -> String {
454 s.replace('&', "&amp;")
455 .replace('<', "&lt;")
456 .replace('>', "&gt;")
457 .replace('"', "&quot;")
458 }
459
460 /// Creates a new email record (draft or imported).
461 #[tauri::command]
462 #[instrument(skip_all)]
463 pub async fn create_email(state: State<'_, Arc<AppState>>, input: EmailInput) -> Result<EmailResponse, ApiError> {
464 if input.subject.trim().is_empty() {
465 return Err(ApiError::validation("subject", "Subject is required"));
466 }
467
468 if !is_valid_email(&input.from_address) {
469 return Err(ApiError::validation("fromAddress", "Invalid 'from' email address"));
470 }
471 if !is_valid_email(&input.to_address) {
472 return Err(ApiError::validation("toAddress", "Invalid 'to' email address"));
473 }
474
475 let new_email = NewEmail {
476 project_id: input.project_id,
477 from_address: input.from_address,
478 to_address: input.to_address,
479 subject: input.subject,
480 body: input.body,
481 is_read: false,
482 received_at: None,
483 };
484
485 let email = state.emails.create(DESKTOP_USER_ID, new_email).await?;
486 Ok(EmailResponse::from(email))
487 }
488
489 /// Save an email draft (create or update).
490 #[tauri::command]
491 #[instrument(skip_all)]
492 pub async fn save_email_draft(state: State<'_, Arc<AppState>>, input: DraftInput) -> Result<EmailResponse, ApiError> {
493 let id = input.id.unwrap_or_else(EmailId::new);
494 let account_id = input.account_id;
495 let from = if let Some(aid) = account_id {
496 let acct = state.email_accounts.get_by_id(aid, DESKTOP_USER_ID).await?;
497 acct.map(|a| a.email_address).unwrap_or_default()
498 } else {
499 String::new()
500 };
501
502 let email = state.emails.save_draft(
503 id, DESKTOP_USER_ID,
504 &from,
505 &input.to_address.unwrap_or_default(),
506 input.cc_address.as_deref(),
507 input.bcc_address.as_deref(),
508 &input.subject.unwrap_or_default(),
509 &input.body.unwrap_or_default(),
510 account_id,
511 input.in_reply_to.as_deref(),
512 input.references.as_deref(),
513 input.thread_id.as_deref(),
514 ).await?;
515
516 Ok(EmailResponse::from(email))
517 }
518
519 /// List all draft emails.
520 #[tauri::command]
521 #[instrument(skip_all)]
522 pub async fn list_email_drafts(state: State<'_, Arc<AppState>>) -> Result<Vec<EmailResponse>, ApiError> {
523 let drafts = state.emails.list_drafts(DESKTOP_USER_ID).await?;
524 Ok(drafts.into_iter().map(EmailResponse::from).collect())
525 }
526
527 /// Send a draft: send via SMTP, delete the draft record, save as sent.
528 #[tauri::command]
529 #[instrument(skip_all)]
530 pub async fn send_email_draft(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<SendEmailResponse, ApiError> {
531 let draft = state.emails.get_by_id(id, DESKTOP_USER_ID).await?
532 .or_not_found("draft", id)?;
533
534 if !draft.is_draft {
535 return Err(ApiError::bad_request("Email is not a draft"));
536 }
537
538 let account_id = draft.draft_account_id
539 .ok_or_else(|| ApiError::validation_msg("Draft has no account selected"))?;
540
541 // Build send input from draft fields
542 let input = SendEmailInput {
543 account_id,
544 to_address: draft.to.clone(),
545 cc_address: draft.cc_address.clone(),
546 bcc_address: draft.bcc_address.clone(),
547 subject: draft.subject.clone(),
548 body: draft.body.clone(),
549 project_id: draft.project_id,
550 in_reply_to: draft.in_reply_to.clone(),
551 references: None,
552 thread_id: draft.thread_id.clone(),
553 attachment_paths: Vec::new(),
554 };
555
556 // Send via the existing send_email logic
557 let result = send_email_inner(&state, input).await?;
558
559 // Delete the draft
560 state.emails.delete(id, DESKTOP_USER_ID).await?;
561
562 Ok(result)
563 }
564
565 /// Sends an email via SMTP and saves a copy locally.
566 #[tauri::command]
567 #[instrument(skip_all)]
568 pub async fn send_email(state: State<'_, Arc<AppState>>, input: SendEmailInput) -> Result<SendEmailResponse, ApiError> {
569 send_email_inner(&state, input).await
570 }
571
572 /// Core send logic shared by `send_email` and `send_email_draft`.
573 ///
574 /// Flow: (1) validate inputs, (2) look up account + credentials,
575 /// (3) send via SMTP (OAuth or password), (4) save outgoing record to
576 /// the local database with `is_outgoing=true` and `source_folder="Sent"`.
577 async fn send_email_inner(state: &Arc<AppState>, input: SendEmailInput) -> Result<SendEmailResponse, ApiError> {
578 if input.subject.trim().is_empty() {
579 return Err(ApiError::validation("subject", "Subject is required"));
580 }
581 if input.to_address.trim().is_empty() {
582 return Err(ApiError::validation("toAddress", "At least one recipient is required"));
583 }
584
585 let account = state.email_accounts
586 .get_by_id(input.account_id, DESKTOP_USER_ID)
587 .await?
588 .or_not_found("emailAccount", input.account_id)?;
589
590 // Read attachment files
591 use crate::email::smtp_client::AttachmentFile;
592 let mut attachment_files = Vec::new();
593 for path_str in &input.attachment_paths {
594 let path = std::path::Path::new(path_str);
595 if !path.is_file() {
596 return Err(ApiError::validation("attachmentPaths", format!("File not found: {}", path_str)));
597 }
598 let data = tokio::fs::read(path).await
599 .map_api_err("Failed to read attachment file", ApiError::internal)?;
600 let filename = path.file_name()
601 .and_then(|n| n.to_str())
602 .unwrap_or("attachment")
603 .to_string();
604 let mime_type = goingson_core::mime_from_extension(&filename).to_string();
605 attachment_files.push(AttachmentFile { filename, mime_type, data });
606 }
607
608 let params = crate::email::smtp_client::SendParams {
609 to: &input.to_address,
610 cc: input.cc_address.as_deref(),
611 bcc: input.bcc_address.as_deref(),
612 subject: &input.subject,
613 body: &input.body,
614 in_reply_to: input.in_reply_to.as_deref(),
615 references: input.references.as_deref(),
616 attachments: attachment_files,
617 };
618
619 let message_id = if uses_jmap(&account) {
620 return Err(ApiError::bad_request("JMAP email sending not yet implemented - use IMAP account"));
621 } else if uses_oauth_imap(&account) {
622 let access_token = get_valid_access_token(&state, &account).await?;
623 let smtp_client = SmtpClient::with_oauth(
624 &account.smtp_server,
625 account.smtp_port as u16,
626 &account.email_address,
627 &access_token,
628 true,
629 );
630 smtp_client
631 .send_message(&params)
632 .await
633 .map_api_err("Failed to send email", ApiError::external_service)?
634 } else {
635 let password = get_account_password(&account)?;
636 let smtp_client = SmtpClient::with_password(&account, &password);
637 smtp_client
638 .send_message(&params)
639 .await
640 .map_api_err("Failed to send email", ApiError::external_service)?
641 };
642
643 // For replies, join the existing thread. For new emails, start a new thread.
644 let thread_id = input.thread_id.unwrap_or_else(|| message_id.clone());
645
646 // Capture recipient addresses before they're moved into new_email
647 let to_for_contacts = input.to_address.clone();
648 let cc_for_contacts = input.cc_address.clone();
649 let bcc_for_contacts = input.bcc_address.clone();
650
651 let new_email = NewEmailWithTracking {
652 project_id: input.project_id,
653 from_address: account.email_address.clone(),
654 to_address: input.to_address,
655 subject: input.subject,
656 body: input.body,
657 html_body: None,
658 is_read: true,
659 is_archived: false,
660 received_at: Some(Utc::now()),
661 message_id: Some(message_id.clone()),
662 in_reply_to: input.in_reply_to,
663 thread_id: Some(thread_id),
664 imap_uid: None,
665 source_folder: Some("Sent".to_string()),
666 email_account_id: Some(input.account_id),
667 is_outgoing: true,
668 attachment_meta: None,
669 };
670
671 let saved = state.emails.create_with_tracking(DESKTOP_USER_ID, new_email).await?;
672
673 // Auto-create implicit contacts for unknown recipients
674 let new_implicit_contacts = create_implicit_contacts(
675 state,
676 &to_for_contacts,
677 cc_for_contacts.as_deref(),
678 bcc_for_contacts.as_deref(),
679 &account.email_address,
680 ).await;
681
682 Ok(SendEmailResponse {
683 success: true,
684 message_id: Some(message_id),
685 saved_email: EmailResponse::from(saved),
686 new_implicit_contacts,
687 })
688 }
689
690 /// Create implicit contacts for recipients that don't have an existing contact.
691 /// Errors are logged and swallowed — never fails the send.
692 async fn create_implicit_contacts(
693 state: &std::sync::Arc<AppState>,
694 to: &str,
695 cc: Option<&str>,
696 bcc: Option<&str>,
697 sender_email: &str,
698 ) -> Vec<super::ContactResponse> {
699 use goingson_db_sqlite::utils::is_valid_email;
700 use goingson_core::{NewContact, NewContactEmail};
701 use std::collections::HashSet;
702
703 // Collect all unique recipient addresses
704 let mut addresses = HashSet::new();
705 for field in [Some(to), cc, bcc].into_iter().flatten() {
706 for addr in field.split(',').map(str::trim).filter(|a| !a.is_empty()) {
707 let lower = addr.to_lowercase();
708 if lower != sender_email.to_lowercase() && is_valid_email(addr) {
709 addresses.insert((addr.to_string(), lower));
710 }
711 }
712 }
713
714 let mut new_contacts = Vec::new();
715
716 for (addr, _lower) in addresses {
717 // Check if a contact already exists for this address
718 match state.contacts.find_by_email(DESKTOP_USER_ID, &addr).await {
719 Ok(Some(_)) => continue, // already exists
720 Ok(None) => {} // proceed to create
721 Err(e) => {
722 tracing::warn!("Failed to check contact for {}: {}", addr, e);
723 continue;
724 }
725 }
726
727 // Derive display name from the local part of the email address
728 let display_name = addr.split('@').next().unwrap_or(&addr)
729 .replace('.', " ")
730 .replace('_', " ")
731 .split_whitespace()
732 .map(|w| {
733 let mut c = w.chars();
734 match c.next() {
735 None => String::new(),
736 Some(f) => f.to_uppercase().to_string() + c.as_str(),
737 }
738 })
739 .collect::<Vec<_>>()
740 .join(" ");
741
742 let new_contact = NewContact {
743 display_name,
744 nickname: None,
745 company: None,
746 title: None,
747 notes: String::new(),
748 tags: vec![],
749 birthday: None,
750 timezone: None,
751 is_implicit: true,
752 };
753
754 match state.contacts.create(DESKTOP_USER_ID, new_contact).await {
755 Ok(contact) => {
756 let email_entry = NewContactEmail {
757 address: addr.clone(),
758 label: String::new(),
759 is_primary: true,
760 };
761 if let Err(e) = state.contacts.add_email(contact.id, DESKTOP_USER_ID, email_entry).await {
762 tracing::warn!("Failed to add email to implicit contact: {}", e);
763 }
764 // Re-fetch to get hydrated contact with email sub-collection
765 match state.contacts.get_by_id(contact.id, DESKTOP_USER_ID).await {
766 Ok(Some(c)) => new_contacts.push(super::ContactResponse::from(c)),
767 _ => new_contacts.push(super::ContactResponse::from(contact)),
768 }
769 }
770 Err(e) => {
771 tracing::warn!("Failed to create implicit contact for {}: {}", addr, e);
772 }
773 }
774 }
775
776 new_contacts
777 }
778
779 /// Set labels on an email.
780 #[tauri::command]
781 #[instrument(skip_all)]
782 pub async fn set_email_labels(state: State<'_, Arc<AppState>>, id: EmailId, labels: Vec<String>) -> Result<EmailResponse, ApiError> {
783 let email = state.emails
784 .update_labels(id, DESKTOP_USER_ID, &labels)
785 .await?
786 .or_not_found("email", id)?;
787 Ok(EmailResponse::from(email))
788 }
789
790 /// List distinct source folders across all emails.
791 #[tauri::command]
792 #[instrument(skip_all)]
793 pub async fn list_email_folders(state: State<'_, Arc<AppState>>) -> Result<Vec<String>, ApiError> {
794 Ok(state.emails.list_folders(DESKTOP_USER_ID).await?)
795 }
796
797 /// List all distinct labels used across emails.
798 #[tauri::command]
799 #[instrument(skip_all)]
800 pub async fn list_email_labels(state: State<'_, Arc<AppState>>) -> Result<Vec<String>, ApiError> {
801 Ok(state.emails.list_labels(DESKTOP_USER_ID).await?)
802 }
803
804 /// Move an email to a different IMAP folder.
805 #[tauri::command]
806 #[instrument(skip_all)]
807 pub async fn move_email_to_folder(
808 state: State<'_, Arc<AppState>>,
809 id: EmailId,
810 folder: String,
811 ) -> Result<bool, ApiError> {
812 let email = state.emails
813 .get_by_id(id, DESKTOP_USER_ID)
814 .await?
815 .or_not_found("email", id)?;
816
817 let current_folder = email.source_folder.as_deref().unwrap_or("INBOX");
818
819 // Attempt IMAP move if the email has an account and UID
820 if let (Some(account_id), Some(uid)) = (email.email_account_id, email.imap_uid) {
821 if let Some((imap_client, _)) = build_imap_client(&state, account_id, "move_to_folder").await {
822 if let Err(e) = imap_client.move_message(uid as u32, current_folder, &folder).await {
823 warn!("IMAP move failed (local update will proceed): {}", e);
824 }
825 }
826 }
827
828 // Always update locally
829 Ok(state.emails.update_source_folder(id, DESKTOP_USER_ID, &folder).await?)
830 }
831
832 /// Deletes an email.
833 #[tauri::command]
834 #[instrument(skip_all)]
835 pub async fn delete_email(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<bool, ApiError> {
836 Ok(state.emails.delete(id, DESKTOP_USER_ID).await?)
837 }
838
839 /// Marks an email as read.
840 #[tauri::command]
841 #[instrument(skip_all)]
842 pub async fn mark_email_read(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<bool, ApiError> {
843 Ok(state.emails.mark_read(id, DESKTOP_USER_ID).await?)
844 }
845
846 /// Marks an email as unread.
847 #[tauri::command]
848 #[instrument(skip_all)]
849 pub async fn mark_email_unread(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<bool, ApiError> {
850 Ok(state.emails.mark_unread(id, DESKTOP_USER_ID).await?)
851 }
852
853 /// Archives an email locally and on the IMAP server.
854 ///
855 /// If the email has an associated IMAP account and UID, attempts to move
856 /// the message to the server's Archive folder via IMAP MOVE. If the IMAP
857 /// move fails (e.g. server unreachable), the local archive still proceeds
858 /// — the next sync will reconcile the mismatch.
859 #[tauri::command]
860 #[instrument(skip_all)]
861 pub async fn archive_email(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<bool, ApiError> {
862 let email = state.emails
863 .get_by_id(id, DESKTOP_USER_ID)
864 .await?
865 .or_not_found("email", id)?;
866
867 if let (Some(account_id), Some(imap_uid)) = (email.email_account_id, email.imap_uid) {
868 if let Some((imap_client, archive_folder)) = build_imap_client(&state, account_id, "archive").await {
869 if let Err(e) = imap_client.archive_message(imap_uid as u32, &archive_folder).await {
870 warn!("Failed to archive on IMAP server: {}", e);
871 }
872 }
873 }
874
875 Ok(state.emails.archive(id, DESKTOP_USER_ID).await?)
876 }
877
878 /// Unarchives an email locally and moves it back to INBOX on the IMAP server.
879 ///
880 /// Same best-effort IMAP sync as `archive_email` — local unarchive always
881 /// succeeds even if the server operation fails.
882 #[tauri::command]
883 #[instrument(skip_all)]
884 pub async fn unarchive_email(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<bool, ApiError> {
885 let email = state.emails
886 .get_by_id(id, DESKTOP_USER_ID)
887 .await?
888 .or_not_found("email", id)?;
889
890 if let (Some(account_id), Some(imap_uid)) = (email.email_account_id, email.imap_uid) {
891 if let Some((imap_client, archive_folder)) = build_imap_client(&state, account_id, "unarchive").await {
892 if let Err(e) = imap_client.unarchive_message(imap_uid as u32, &archive_folder).await {
893 warn!("Failed to unarchive on IMAP server: {}", e);
894 }
895 }
896 }
897
898 Ok(state.emails.unarchive(id, DESKTOP_USER_ID).await?)
899 }
900
901 /// Marks all emails as read.
902 #[tauri::command]
903 #[instrument(skip_all)]
904 pub async fn mark_all_emails_read(state: State<'_, Arc<AppState>>) -> Result<u64, ApiError> {
905 Ok(state.emails.mark_all_read(DESKTOP_USER_ID).await?)
906 }
907
908 /// Links an email to a project.
909 #[tauri::command]
910 #[instrument(skip_all)]
911 pub async fn link_email_to_project(state: State<'_, Arc<AppState>>, id: EmailId, input: LinkProjectInput) -> Result<bool, ApiError> {
912 Ok(state.emails.link_to_project(id, DESKTOP_USER_ID, input.project_id).await?)
913 }
914
915 /// Gets the count of unread emails.
916 #[tauri::command]
917 #[instrument(skip_all)]
918 pub async fn get_unread_email_count(state: State<'_, Arc<AppState>>) -> Result<UnreadCountResponse, ApiError> {
919 let count = state.emails.count_unread(DESKTOP_USER_ID).await?;
920 Ok(UnreadCountResponse { count })
921 }
922
923 /// Lists all snoozed emails.
924 #[tauri::command]
925 #[instrument(skip_all)]
926 pub async fn list_snoozed_emails(state: State<'_, Arc<AppState>>) -> Result<Vec<EmailResponse>, ApiError> {
927 let emails = state.emails.list_snoozed(DESKTOP_USER_ID).await?;
928 Ok(emails.into_iter().map(EmailResponse::from).collect())
929 }
930
931 /// Snoozes an email until a specified time.
932 #[tauri::command]
933 #[instrument(skip_all)]
934 pub async fn snooze_email(state: State<'_, Arc<AppState>>, id: EmailId, input: SnoozeInput) -> Result<EmailResponse, ApiError> {
935 let email = state.emails
936 .snooze(id, DESKTOP_USER_ID, input.until)
937 .await?
938 .or_not_found("email", id)?;
939 Ok(EmailResponse::from(email))
940 }
941
942 /// Unsnoozes an email.
943 #[tauri::command]
944 #[instrument(skip_all)]
945 pub async fn unsnooze_email(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<EmailResponse, ApiError> {
946 let email = state.emails
947 .unsnooze(id, DESKTOP_USER_ID)
948 .await?
949 .or_not_found("email", id)?;
950 Ok(EmailResponse::from(email))
951 }
952
953 /// Lists all emails marked as waiting for response.
954 #[tauri::command]
955 #[instrument(skip_all)]
956 pub async fn list_waiting_emails(state: State<'_, Arc<AppState>>) -> Result<Vec<EmailResponse>, ApiError> {
957 let emails = state.emails.list_waiting(DESKTOP_USER_ID).await?;
958 Ok(emails.into_iter().map(EmailResponse::from).collect())
959 }
960
961 /// Marks an email as waiting for response.
962 #[tauri::command]
963 #[instrument(skip_all)]
964 pub async fn mark_email_waiting(state: State<'_, Arc<AppState>>, id: EmailId, input: WaitingInput) -> Result<EmailResponse, ApiError> {
965 let email = state.emails
966 .mark_waiting(id, DESKTOP_USER_ID, input.expected_response_date)
967 .await?
968 .or_not_found("email", id)?;
969 Ok(EmailResponse::from(email))
970 }
971
972 /// Clears the waiting status from an email.
973 #[tauri::command]
974 #[instrument(skip_all)]
975 pub async fn clear_email_waiting(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<EmailResponse, ApiError> {
976 let email = state.emails
977 .clear_waiting(id, DESKTOP_USER_ID)
978 .await?
979 .or_not_found("email", id)?;
980 Ok(EmailResponse::from(email))
981 }
982
983 // ============ Project Dashboard Commands ============
984
985 /// Lists all emails for a specific project.
986 #[tauri::command]
987 #[instrument(skip_all)]
988 pub async fn list_emails_for_project(state: State<'_, Arc<AppState>>, project_id: ProjectId) -> Result<Vec<EmailResponse>, ApiError> {
989 let emails = state.emails.list_by_project(DESKTOP_USER_ID, project_id).await?;
990 Ok(emails.into_iter().map(EmailResponse::from).collect())
991 }
992
993 /// Lists emails not linked to any project.
994 #[tauri::command]
995 #[instrument(skip_all)]
996 pub async fn list_unlinked_emails(state: State<'_, Arc<AppState>>) -> Result<Vec<EmailResponse>, ApiError> {
997 let emails = state.emails.list_unlinked(DESKTOP_USER_ID).await?;
998 Ok(emails.into_iter().map(EmailResponse::from).collect())
999 }
1000
1001 /// Lists all emails in a thread.
1002 #[tauri::command]
1003 #[instrument(skip_all)]
1004 pub async fn list_emails_by_thread(state: State<'_, Arc<AppState>>, thread_id: String) -> Result<Vec<EmailResponse>, ApiError> {
1005 let emails = state.emails.list_by_thread(DESKTOP_USER_ID, &thread_id).await?;
1006 Ok(emails.into_iter().map(EmailResponse::from).collect())
1007 }
1008