Skip to main content

max / goingson

27.5 KB · 916 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::{
15 AttachmentMeta, Email, EmailAccountId, EmailId, NewEmail, NewEmailWithTracking, ProjectId,
16 };
17
18 use super::email_account::{get_account_password, get_valid_access_token, uses_oauth_imap};
19 use super::{
20 ApiError, LinkProjectInput, OptionApiError, OptionNotFound, ResultApiError, SnoozeInput,
21 WaitingInput,
22 };
23 use crate::email::{ImapClient, SmtpClient, uses_jmap};
24 use crate::state::{AppState, DESKTOP_USER_ID};
25 use goingson_core::date_utils::{format_elapsed_time, format_relative_future};
26 use goingson_db_sqlite::utils::is_valid_email;
27
28 mod contacts;
29 mod preview;
30 mod send;
31
32 pub use preview::*;
33 use send::{build_imap_client, send_email_inner};
34
35 // IMAP Client Helper
36
37 // Types
38
39 #[derive(Debug, Deserialize)]
40 #[serde(rename_all = "camelCase")]
41 pub struct EmailInput {
42 pub project_id: Option<ProjectId>,
43 pub from_address: String,
44 pub to_address: String,
45 pub subject: String,
46 pub body: String,
47 }
48
49 #[derive(Debug, Deserialize)]
50 #[serde(rename_all = "camelCase")]
51 pub struct SendEmailInput {
52 pub account_id: EmailAccountId,
53 pub to_address: String,
54 pub cc_address: Option<String>,
55 pub bcc_address: Option<String>,
56 pub subject: String,
57 pub body: String,
58 pub project_id: Option<ProjectId>,
59 /// Message-ID of the email being replied to (sets In-Reply-To header).
60 pub in_reply_to: Option<String>,
61 /// Full References header chain for threading.
62 pub references: Option<String>,
63 /// Thread ID to join (from the original email's thread).
64 pub thread_id: Option<String>,
65 /// File paths to attach (from file picker dialog).
66 #[serde(default)]
67 pub attachment_paths: Vec<String>,
68 }
69
70 #[derive(Debug, Deserialize)]
71 #[serde(rename_all = "camelCase")]
72 pub struct DraftInput {
73 /// If updating an existing draft, pass its ID.
74 pub id: Option<EmailId>,
75 pub account_id: Option<EmailAccountId>,
76 pub to_address: Option<String>,
77 pub cc_address: Option<String>,
78 pub bcc_address: Option<String>,
79 pub subject: Option<String>,
80 pub body: Option<String>,
81 pub in_reply_to: Option<String>,
82 pub references: Option<String>,
83 pub thread_id: Option<String>,
84 }
85
86 #[derive(Debug, Serialize)]
87 #[serde(rename_all = "camelCase")]
88 pub struct UnreadCountResponse {
89 pub count: i64,
90 }
91
92 /// Email response with pre-computed fields for UI.
93 #[derive(Debug, Serialize)]
94 #[serde(rename_all = "camelCase")]
95 pub struct EmailResponse {
96 pub id: EmailId,
97 pub project_id: Option<ProjectId>,
98 pub project_name: Option<String>,
99 pub from: String,
100 pub to: String,
101 pub subject: String,
102 pub body: String,
103 pub html_body: Option<String>,
104 /// Char-safe ~100-char plaintext preview for the list view (Rust does the slice).
105 pub body_preview: String,
106 /// Whether the body was truncated at sync and can be re-fetched in full.
107 pub body_truncated: bool,
108 pub is_read: bool,
109 pub is_archived: bool,
110 pub received_at: chrono::DateTime<Utc>,
111 pub message_id: Option<String>,
112 pub in_reply_to: Option<String>,
113 pub thread_id: Option<String>,
114 pub email_account_id: Option<EmailAccountId>,
115 pub is_outgoing: bool,
116 pub snoozed_until: Option<chrono::DateTime<Utc>>,
117 pub waiting_for_response: bool,
118 pub waiting_since: Option<chrono::DateTime<Utc>>,
119 pub expected_response_date: Option<chrono::DateTime<Utc>>,
120 // Pre-computed fields
121 pub is_snoozed: bool,
122 pub is_waiting: bool,
123 pub is_response_overdue: bool,
124 pub received_formatted: String,
125 /// Human-readable snooze time: "today", "tomorrow", "+3d", "Mar 15"
126 pub snoozed_until_formatted: Option<String>,
127 /// Parsed attachment metadata from IMAP sync (filename, size, mime_type, blob_hash).
128 pub attachments: Vec<EmailAttachmentInfo>,
129 /// IMAP source folder (INBOX, Sent, Archive, etc.).
130 pub source_folder: Option<String>,
131 /// Local labels/tags.
132 pub labels: Vec<String>,
133 /// Whether this email is a draft.
134 pub is_draft: bool,
135 /// CC recipients (drafts).
136 pub cc_address: Option<String>,
137 /// BCC recipients (drafts).
138 pub bcc_address: Option<String>,
139 /// Account ID to send from (drafts).
140 pub draft_account_id: Option<EmailAccountId>,
141 }
142
143 /// Attachment info exposed to the frontend for display.
144 #[derive(Debug, Serialize)]
145 #[serde(rename_all = "camelCase")]
146 pub struct EmailAttachmentInfo {
147 pub filename: String,
148 pub mime_type: String,
149 pub size: usize,
150 pub blob_hash: String,
151 pub size_formatted: String,
152 }
153
154 impl From<Email> for EmailResponse {
155 fn from(e: Email) -> Self {
156 let is_snoozed = e.is_snoozed();
157 let is_waiting = e.is_waiting();
158 let is_response_overdue = e.is_response_overdue();
159
160 let now = Utc::now();
161 let snoozed_until_formatted = e.snoozed_until.map(|s| format_relative_future(s, now));
162 let received_formatted = format_elapsed_time(e.received_at, now);
163
164 // Char-boundary-safe preview (byte slicing would panic on multibyte content).
165 let body_preview: String = e.body.chars().take(100).collect();
166
167 let attachments = e
168 .attachment_meta
169 .as_deref()
170 .and_then(|json| serde_json::from_str::<Vec<AttachmentMeta>>(json).ok())
171 .unwrap_or_default()
172 .into_iter()
173 .map(|m| EmailAttachmentInfo {
174 size_formatted: goingson_core::format_file_size(m.size as i64),
175 filename: m.filename,
176 mime_type: m.mime_type,
177 size: m.size,
178 blob_hash: m.blob_hash,
179 })
180 .collect();
181
182 EmailResponse {
183 id: e.id,
184 project_id: e.project_id,
185 project_name: e.project_name,
186 from: e.from,
187 to: e.to,
188 subject: e.subject,
189 body: e.body,
190 html_body: e.html_body,
191 body_preview,
192 body_truncated: e.body_truncated,
193 is_read: e.is_read,
194 is_archived: e.is_archived,
195 received_at: e.received_at,
196 message_id: e.message_id,
197 in_reply_to: e.in_reply_to,
198 thread_id: e.thread_id,
199 email_account_id: e.email_account_id,
200 is_outgoing: e.is_outgoing,
201 snoozed_until: e.snoozed_until,
202 waiting_for_response: e.waiting_for_response,
203 waiting_since: e.waiting_since,
204 expected_response_date: e.expected_response_date,
205 is_snoozed,
206 is_waiting,
207 is_response_overdue,
208 received_formatted,
209 snoozed_until_formatted,
210 attachments,
211 source_folder: e.source_folder,
212 labels: e.labels,
213 is_draft: e.is_draft,
214 cc_address: e.cc_address,
215 bcc_address: e.bcc_address,
216 draft_account_id: e.draft_account_id,
217 }
218 }
219 }
220
221 /// A thread of emails, pre-grouped by the backend.
222 #[derive(Debug, Serialize)]
223 #[serde(rename_all = "camelCase")]
224 pub struct EmailThreadResponse {
225 pub thread_id: String,
226 pub most_recent_email: EmailResponse,
227 pub thread_count: usize,
228 pub has_unread: bool,
229 }
230
231 /// Pagination parameters for email listing.
232 #[derive(Debug, Default, Deserialize)]
233 #[serde(rename_all = "camelCase")]
234 pub struct EmailPaginationInput {
235 #[serde(default)]
236 pub include_archived: bool,
237 pub offset: Option<i64>,
238 pub limit: Option<i64>,
239 /// Filter by source folder (e.g. "INBOX", "Sent", "Archive").
240 pub folder: Option<String>,
241 /// Filter by label.
242 pub label: Option<String>,
243 }
244
245 /// Paginated response with total count for UI pagination.
246 #[derive(Debug, Serialize)]
247 #[serde(rename_all = "camelCase")]
248 pub struct PaginatedEmailThreadsResponse {
249 pub threads: Vec<EmailThreadResponse>,
250 pub total: i64,
251 }
252
253 #[derive(Debug, Serialize)]
254 #[serde(rename_all = "camelCase")]
255 pub struct SendEmailResponse {
256 pub success: bool,
257 pub message_id: Option<String>,
258 pub saved_email: EmailResponse,
259 pub new_implicit_contacts: Vec<super::ContactResponse>,
260 }
261
262 // Email Commands
263
264 /// Lists all emails for the current user.
265 #[tauri::command]
266 #[instrument(skip_all)]
267 pub async fn list_emails(
268 state: State<'_, Arc<AppState>>,
269 include_archived: Option<bool>,
270 ) -> Result<Vec<EmailResponse>, ApiError> {
271 // Body-less + capped: this flat list never renders a body (the reader fetches it
272 // via get_by_id), so it must not materialize every body at once (Perf S3).
273 let emails = state
274 .emails
275 .list_metadata(DESKTOP_USER_ID, include_archived.unwrap_or(false))
276 .await?;
277 Ok(emails.into_iter().map(EmailResponse::from).collect())
278 }
279
280 /// Lists emails grouped by thread with pagination.
281 ///
282 /// The repository groups emails by `thread_id` (set during sync, see
283 /// `process_fetched_emails`), returns the most recent email per thread,
284 /// and includes unread status and thread depth for the UI.
285 #[tauri::command]
286 #[instrument(skip_all)]
287 pub async fn list_emails_threaded(
288 state: State<'_, Arc<AppState>>,
289 params: Option<EmailPaginationInput>,
290 ) -> Result<PaginatedEmailThreadsResponse, ApiError> {
291 let params = params.unwrap_or_default();
292
293 let (threads, total) = state
294 .emails
295 .list_threaded(
296 DESKTOP_USER_ID,
297 params.include_archived,
298 params.offset,
299 params.limit,
300 params.folder.as_deref(),
301 params.label.as_deref(),
302 )
303 .await?;
304
305 Ok(PaginatedEmailThreadsResponse {
306 threads: threads
307 .into_iter()
308 .map(|t| EmailThreadResponse {
309 thread_id: t.thread_id,
310 most_recent_email: EmailResponse::from(t.most_recent_email),
311 thread_count: t.thread_count,
312 has_unread: t.has_unread,
313 })
314 .collect(),
315 total,
316 })
317 }
318
319 /// Retrieves a single email by ID.
320 #[tauri::command]
321 #[instrument(skip_all)]
322 pub async fn get_email(
323 state: State<'_, Arc<AppState>>,
324 id: EmailId,
325 ) -> Result<Option<EmailResponse>, ApiError> {
326 let email = state.emails.get_by_id(id, DESKTOP_USER_ID).await?;
327 Ok(email.map(EmailResponse::from))
328 }
329
330 /// Prefill for the compose window when replying or forwarding.
331 ///
332 /// All the domain logic (recipient assembly, `Re:`/`Fwd:` prefixing, quoting)
333 /// is computed in Rust so the frontend just renders the result.
334 #[derive(Debug, Serialize)]
335 #[serde(rename_all = "camelCase")]
336 pub struct ComposePrefillResponse {
337 pub to: String,
338 pub subject: String,
339 pub body: String,
340 pub in_reply_to: Option<String>,
341 pub references: Option<String>,
342 pub thread_id: Option<String>,
343 /// Account to send from ("" when the source email has no account).
344 pub account_id: String,
345 }
346
347 /// Format an email timestamp for a reply/forward attribution line, in local time.
348 fn format_attribution_date(dt: chrono::DateTime<Utc>) -> String {
349 dt.with_timezone(&chrono::Local)
350 .format("%b %-d, %Y, %-I:%M %p")
351 .to_string()
352 }
353
354 /// Builds the compose prefill for replying to an email.
355 ///
356 /// Reply-all assembles sender + original recipients, excluding the user's own
357 /// account addresses (authoritative, from the account repo, not a browser
358 /// cache) and de-duplicating.
359 #[tauri::command]
360 #[instrument(skip_all)]
361 pub async fn build_reply_prefill(
362 state: State<'_, Arc<AppState>>,
363 id: EmailId,
364 reply_all: bool,
365 ) -> Result<ComposePrefillResponse, ApiError> {
366 let email = state
367 .emails
368 .get_by_id(id, DESKTOP_USER_ID)
369 .await?
370 .or_not_found("email", id)?;
371
372 let own_addresses: Vec<String> = state
373 .email_accounts
374 .list_by_user(DESKTOP_USER_ID)
375 .await?
376 .into_iter()
377 .map(|a| a.email_address)
378 .collect();
379
380 let to = goingson_core::reply_recipients(&email.from, &email.to, &own_addresses, reply_all);
381 let subject = goingson_core::reply_subject(&email.subject);
382 let date = format_attribution_date(email.received_at);
383 let body = goingson_core::quoted_reply_body(&email.from, &date, &email.body);
384
385 Ok(ComposePrefillResponse {
386 to,
387 subject,
388 body,
389 in_reply_to: email.message_id.clone(),
390 references: email.message_id,
391 thread_id: email.thread_id,
392 account_id: email
393 .email_account_id
394 .map(|a| a.to_string())
395 .unwrap_or_default(),
396 })
397 }
398
399 /// Builds the compose prefill for forwarding an email.
400 #[tauri::command]
401 #[instrument(skip_all)]
402 pub async fn build_forward_prefill(
403 state: State<'_, Arc<AppState>>,
404 id: EmailId,
405 ) -> Result<ComposePrefillResponse, ApiError> {
406 let email = state
407 .emails
408 .get_by_id(id, DESKTOP_USER_ID)
409 .await?
410 .or_not_found("email", id)?;
411
412 let subject = goingson_core::forward_subject(&email.subject);
413 let date = format_attribution_date(email.received_at);
414 let body =
415 goingson_core::forward_body(&email.from, &date, &email.subject, &email.to, &email.body);
416
417 Ok(ComposePrefillResponse {
418 to: String::new(),
419 subject,
420 body,
421 in_reply_to: None,
422 references: None,
423 thread_id: None,
424 account_id: email
425 .email_account_id
426 .map(|a| a.to_string())
427 .unwrap_or_default(),
428 })
429 }
430
431 /// Fetches the full body of a JMAP email whose body was truncated at sync
432 /// (>100KB). Re-fetches via the provider, stores the full body, clears the
433 /// truncation flag, and returns the body. If the email was not truncated, the
434 /// already-stored body is returned unchanged.
435 #[tauri::command]
436 #[instrument(skip_all)]
437 pub async fn fetch_email_full_body(
438 state: State<'_, Arc<AppState>>,
439 id: EmailId,
440 ) -> Result<String, ApiError> {
441 let email = state
442 .emails
443 .get_by_id(id, DESKTOP_USER_ID)
444 .await?
445 .or_not_found("email", id)?;
446
447 if !email.body_truncated {
448 return Ok(email.body);
449 }
450
451 let jmap_id = email
452 .jmap_id
453 .or_api_err(|| ApiError::bad_request("Email has no JMAP id; full body unavailable"))?;
454 let account_id = email
455 .email_account_id
456 .or_api_err(|| ApiError::bad_request("Email has no account; cannot re-fetch body"))?;
457 let account = state
458 .email_accounts
459 .get_by_id(account_id, DESKTOP_USER_ID)
460 .await?
461 .or_not_found("emailAccount", account_id)?;
462
463 let mut client = super::email_sync::build_jmap_client(state.inner(), &account).await?;
464 let body = client.fetch_email_full_body(&jmap_id).await.map_api_err(
465 "Failed to fetch full email body",
466 ApiError::external_service,
467 )?;
468
469 state
470 .emails
471 .set_full_body(id, DESKTOP_USER_ID, &body)
472 .await?;
473 Ok(body)
474 }
475
476 /// Creates a new email record (draft or imported).
477 #[tauri::command]
478 #[instrument(skip_all)]
479 pub async fn create_email(
480 state: State<'_, Arc<AppState>>,
481 input: EmailInput,
482 ) -> Result<EmailResponse, ApiError> {
483 if input.subject.trim().is_empty() {
484 return Err(ApiError::validation("subject", "Subject is required"));
485 }
486
487 if !is_valid_email(&input.from_address) {
488 return Err(ApiError::validation(
489 "fromAddress",
490 "Invalid 'from' email address",
491 ));
492 }
493 if !is_valid_email(&input.to_address) {
494 return Err(ApiError::validation(
495 "toAddress",
496 "Invalid 'to' email address",
497 ));
498 }
499
500 let new_email = NewEmail {
501 project_id: input.project_id,
502 from_address: input.from_address,
503 to_address: input.to_address,
504 subject: input.subject,
505 body: input.body,
506 is_read: false,
507 received_at: None,
508 };
509
510 let email = state.emails.create(DESKTOP_USER_ID, new_email).await?;
511 Ok(EmailResponse::from(email))
512 }
513
514 /// Save an email draft (create or update).
515 #[tauri::command]
516 #[instrument(skip_all)]
517 pub async fn save_email_draft(
518 state: State<'_, Arc<AppState>>,
519 input: DraftInput,
520 ) -> Result<EmailResponse, ApiError> {
521 let id = input.id.unwrap_or_default();
522 let account_id = input.account_id;
523 let from = if let Some(aid) = account_id {
524 let acct = state.email_accounts.get_by_id(aid, DESKTOP_USER_ID).await?;
525 acct.map(|a| a.email_address).unwrap_or_default()
526 } else {
527 String::new()
528 };
529
530 let email = state
531 .emails
532 .save_draft(
533 id,
534 DESKTOP_USER_ID,
535 &from,
536 &input.to_address.unwrap_or_default(),
537 input.cc_address.as_deref(),
538 input.bcc_address.as_deref(),
539 &input.subject.unwrap_or_default(),
540 &input.body.unwrap_or_default(),
541 account_id,
542 input.in_reply_to.as_deref(),
543 input.references.as_deref(),
544 input.thread_id.as_deref(),
545 )
546 .await?;
547
548 Ok(EmailResponse::from(email))
549 }
550
551 /// List all draft emails.
552 #[tauri::command]
553 #[instrument(skip_all)]
554 pub async fn list_email_drafts(
555 state: State<'_, Arc<AppState>>,
556 ) -> Result<Vec<EmailResponse>, ApiError> {
557 let drafts = state.emails.list_drafts(DESKTOP_USER_ID).await?;
558 Ok(drafts.into_iter().map(EmailResponse::from).collect())
559 }
560
561 /// Send a draft: send via SMTP, delete the draft record, save as sent.
562 #[tauri::command]
563 #[instrument(skip_all)]
564 pub async fn send_email_draft(
565 state: State<'_, Arc<AppState>>,
566 id: EmailId,
567 ) -> Result<SendEmailResponse, ApiError> {
568 let draft = state
569 .emails
570 .get_by_id(id, DESKTOP_USER_ID)
571 .await?
572 .or_not_found("draft", id)?;
573
574 if !draft.is_draft {
575 return Err(ApiError::bad_request("Email is not a draft"));
576 }
577
578 let account_id = draft
579 .draft_account_id
580 .ok_or_else(|| ApiError::validation_msg("Draft has no account selected"))?;
581
582 // Build send input from draft fields
583 let input = SendEmailInput {
584 account_id,
585 to_address: draft.to.clone(),
586 cc_address: draft.cc_address.clone(),
587 bcc_address: draft.bcc_address.clone(),
588 subject: draft.subject.clone(),
589 body: draft.body.clone(),
590 project_id: draft.project_id,
591 in_reply_to: draft.in_reply_to.clone(),
592 references: None,
593 thread_id: draft.thread_id.clone(),
594 attachment_paths: Vec::new(),
595 };
596
597 // Send via the existing send_email logic
598 let result = send_email_inner(&state, input).await?;
599
600 // Delete the draft
601 state.emails.delete(id, DESKTOP_USER_ID).await?;
602
603 Ok(result)
604 }
605
606 /// Sends an email via SMTP and saves a copy locally.
607 #[tauri::command]
608 #[instrument(skip_all)]
609 pub async fn send_email(
610 state: State<'_, Arc<AppState>>,
611 input: SendEmailInput,
612 ) -> Result<SendEmailResponse, ApiError> {
613 send_email_inner(&state, input).await
614 }
615
616 /// Set labels on an email.
617 #[tauri::command]
618 #[instrument(skip_all)]
619 pub async fn set_email_labels(
620 state: State<'_, Arc<AppState>>,
621 id: EmailId,
622 labels: Vec<String>,
623 ) -> Result<EmailResponse, ApiError> {
624 let email = state
625 .emails
626 .update_labels(id, DESKTOP_USER_ID, &labels)
627 .await?
628 .or_not_found("email", id)?;
629 Ok(EmailResponse::from(email))
630 }
631
632 /// List distinct source folders across all emails.
633 #[tauri::command]
634 #[instrument(skip_all)]
635 pub async fn list_email_folders(state: State<'_, Arc<AppState>>) -> Result<Vec<String>, ApiError> {
636 Ok(state.emails.list_folders(DESKTOP_USER_ID).await?)
637 }
638
639 /// List all distinct labels used across emails.
640 #[tauri::command]
641 #[instrument(skip_all)]
642 pub async fn list_email_labels(state: State<'_, Arc<AppState>>) -> Result<Vec<String>, ApiError> {
643 Ok(state.emails.list_labels(DESKTOP_USER_ID).await?)
644 }
645
646 /// Move an email to a different IMAP folder.
647 #[tauri::command]
648 #[instrument(skip_all)]
649 pub async fn move_email_to_folder(
650 state: State<'_, Arc<AppState>>,
651 id: EmailId,
652 folder: String,
653 ) -> Result<bool, ApiError> {
654 let email = state
655 .emails
656 .get_by_id(id, DESKTOP_USER_ID)
657 .await?
658 .or_not_found("email", id)?;
659
660 let current_folder = email.source_folder.as_deref().unwrap_or("INBOX");
661
662 // Attempt IMAP move if the email has an account and UID
663 if let (Some(account_id), Some(uid)) = (email.email_account_id, email.imap_uid)
664 && let Some((imap_client, _)) =
665 build_imap_client(&state, account_id, "move_to_folder").await
666 && let Err(e) = imap_client
667 .move_message(uid as u32, current_folder, &folder)
668 .await
669 {
670 warn!("IMAP move failed (local update will proceed): {}", e);
671 }
672
673 // Always update locally
674 Ok(state
675 .emails
676 .update_source_folder(id, DESKTOP_USER_ID, &folder)
677 .await?)
678 }
679
680 /// Deletes an email.
681 #[tauri::command]
682 #[instrument(skip_all)]
683 pub async fn delete_email(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<bool, ApiError> {
684 Ok(state.emails.delete(id, DESKTOP_USER_ID).await?)
685 }
686
687 /// Marks an email as read.
688 #[tauri::command]
689 #[instrument(skip_all)]
690 pub async fn mark_email_read(
691 state: State<'_, Arc<AppState>>,
692 id: EmailId,
693 ) -> Result<bool, ApiError> {
694 Ok(state.emails.mark_read(id, DESKTOP_USER_ID).await?)
695 }
696
697 /// Marks an email as unread.
698 #[tauri::command]
699 #[instrument(skip_all)]
700 pub async fn mark_email_unread(
701 state: State<'_, Arc<AppState>>,
702 id: EmailId,
703 ) -> Result<bool, ApiError> {
704 Ok(state.emails.mark_unread(id, DESKTOP_USER_ID).await?)
705 }
706
707 /// Archives an email locally and on the IMAP server.
708 ///
709 /// If the email has an associated IMAP account and UID, attempts to move
710 /// the message to the server's Archive folder via IMAP MOVE. If the IMAP
711 /// move fails (e.g. server unreachable), the local archive still proceeds
712 ///, the next sync will reconcile the mismatch.
713 #[tauri::command]
714 #[instrument(skip_all)]
715 pub async fn archive_email(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<bool, ApiError> {
716 let email = state
717 .emails
718 .get_by_id(id, DESKTOP_USER_ID)
719 .await?
720 .or_not_found("email", id)?;
721
722 if let (Some(account_id), Some(imap_uid)) = (email.email_account_id, email.imap_uid)
723 && let Some((imap_client, archive_folder)) =
724 build_imap_client(&state, account_id, "archive").await
725 && let Err(e) = imap_client
726 .archive_message(imap_uid as u32, &archive_folder)
727 .await
728 {
729 warn!("Failed to archive on IMAP server: {}", e);
730 }
731
732 Ok(state.emails.archive(id, DESKTOP_USER_ID).await?)
733 }
734
735 /// Unarchives an email locally and moves it back to INBOX on the IMAP server.
736 ///
737 /// Same best-effort IMAP sync as `archive_email`, local unarchive always
738 /// succeeds even if the server operation fails.
739 #[tauri::command]
740 #[instrument(skip_all)]
741 pub async fn unarchive_email(
742 state: State<'_, Arc<AppState>>,
743 id: EmailId,
744 ) -> Result<bool, ApiError> {
745 let email = state
746 .emails
747 .get_by_id(id, DESKTOP_USER_ID)
748 .await?
749 .or_not_found("email", id)?;
750
751 if let (Some(account_id), Some(imap_uid)) = (email.email_account_id, email.imap_uid)
752 && let Some((imap_client, archive_folder)) =
753 build_imap_client(&state, account_id, "unarchive").await
754 && let Err(e) = imap_client
755 .unarchive_message(imap_uid as u32, &archive_folder)
756 .await
757 {
758 warn!("Failed to unarchive on IMAP server: {}", e);
759 }
760
761 Ok(state.emails.unarchive(id, DESKTOP_USER_ID).await?)
762 }
763
764 /// Marks all emails as read.
765 #[tauri::command]
766 #[instrument(skip_all)]
767 pub async fn mark_all_emails_read(state: State<'_, Arc<AppState>>) -> Result<u64, ApiError> {
768 Ok(state.emails.mark_all_read(DESKTOP_USER_ID).await?)
769 }
770
771 /// Links an email to a project.
772 #[tauri::command]
773 #[instrument(skip_all)]
774 pub async fn link_email_to_project(
775 state: State<'_, Arc<AppState>>,
776 id: EmailId,
777 input: LinkProjectInput,
778 ) -> Result<bool, ApiError> {
779 Ok(state
780 .emails
781 .link_to_project(id, DESKTOP_USER_ID, input.project_id)
782 .await?)
783 }
784
785 /// Gets the count of unread emails.
786 #[tauri::command]
787 #[instrument(skip_all)]
788 pub async fn get_unread_email_count(
789 state: State<'_, Arc<AppState>>,
790 ) -> Result<UnreadCountResponse, ApiError> {
791 let count = state.emails.count_unread(DESKTOP_USER_ID).await?;
792 Ok(UnreadCountResponse { count })
793 }
794
795 /// Lists all snoozed emails.
796 #[tauri::command]
797 #[instrument(skip_all)]
798 pub async fn list_snoozed_emails(
799 state: State<'_, Arc<AppState>>,
800 ) -> Result<Vec<EmailResponse>, ApiError> {
801 let emails = state.emails.list_snoozed(DESKTOP_USER_ID).await?;
802 Ok(emails.into_iter().map(EmailResponse::from).collect())
803 }
804
805 /// Snoozes an email until a specified time.
806 #[tauri::command]
807 #[instrument(skip_all)]
808 pub async fn snooze_email(
809 state: State<'_, Arc<AppState>>,
810 id: EmailId,
811 input: SnoozeInput,
812 ) -> Result<EmailResponse, ApiError> {
813 let email = state
814 .emails
815 .snooze(id, DESKTOP_USER_ID, input.until)
816 .await?
817 .or_not_found("email", id)?;
818 Ok(EmailResponse::from(email))
819 }
820
821 /// Unsnoozes an email.
822 #[tauri::command]
823 #[instrument(skip_all)]
824 pub async fn unsnooze_email(
825 state: State<'_, Arc<AppState>>,
826 id: EmailId,
827 ) -> Result<EmailResponse, ApiError> {
828 let email = state
829 .emails
830 .unsnooze(id, DESKTOP_USER_ID)
831 .await?
832 .or_not_found("email", id)?;
833 Ok(EmailResponse::from(email))
834 }
835
836 /// Lists all emails marked as waiting for response.
837 #[tauri::command]
838 #[instrument(skip_all)]
839 pub async fn list_waiting_emails(
840 state: State<'_, Arc<AppState>>,
841 ) -> Result<Vec<EmailResponse>, ApiError> {
842 let emails = state.emails.list_waiting(DESKTOP_USER_ID).await?;
843 Ok(emails.into_iter().map(EmailResponse::from).collect())
844 }
845
846 /// Marks an email as waiting for response.
847 #[tauri::command]
848 #[instrument(skip_all)]
849 pub async fn mark_email_waiting(
850 state: State<'_, Arc<AppState>>,
851 id: EmailId,
852 input: WaitingInput,
853 ) -> Result<EmailResponse, ApiError> {
854 let email = state
855 .emails
856 .mark_waiting(id, DESKTOP_USER_ID, input.expected_response_date)
857 .await?
858 .or_not_found("email", id)?;
859 Ok(EmailResponse::from(email))
860 }
861
862 /// Clears the waiting status from an email.
863 #[tauri::command]
864 #[instrument(skip_all)]
865 pub async fn clear_email_waiting(
866 state: State<'_, Arc<AppState>>,
867 id: EmailId,
868 ) -> Result<EmailResponse, ApiError> {
869 let email = state
870 .emails
871 .clear_waiting(id, DESKTOP_USER_ID)
872 .await?
873 .or_not_found("email", id)?;
874 Ok(EmailResponse::from(email))
875 }
876
877 // Project Dashboard Commands
878
879 /// Lists all emails for a specific project.
880 #[tauri::command]
881 #[instrument(skip_all)]
882 pub async fn list_emails_for_project(
883 state: State<'_, Arc<AppState>>,
884 project_id: ProjectId,
885 ) -> Result<Vec<EmailResponse>, ApiError> {
886 let emails = state
887 .emails
888 .list_by_project(DESKTOP_USER_ID, project_id)
889 .await?;
890 Ok(emails.into_iter().map(EmailResponse::from).collect())
891 }
892
893 /// Lists emails not linked to any project.
894 #[tauri::command]
895 #[instrument(skip_all)]
896 pub async fn list_unlinked_emails(
897 state: State<'_, Arc<AppState>>,
898 ) -> Result<Vec<EmailResponse>, ApiError> {
899 let emails = state.emails.list_unlinked(DESKTOP_USER_ID).await?;
900 Ok(emails.into_iter().map(EmailResponse::from).collect())
901 }
902
903 /// Lists all emails in a thread.
904 #[tauri::command]
905 #[instrument(skip_all)]
906 pub async fn list_emails_by_thread(
907 state: State<'_, Arc<AppState>>,
908 thread_id: String,
909 ) -> Result<Vec<EmailResponse>, ApiError> {
910 let emails = state
911 .emails
912 .list_by_thread(DESKTOP_USER_ID, &thread_id)
913 .await?;
914 Ok(emails.into_iter().map(EmailResponse::from).collect())
915 }
916