Skip to main content

max / goingson

26.5 KB · 752 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, 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,
501 to_address: draft.to.clone(),
502 cc_address: draft.cc_address.clone(),
503 bcc_address: draft.bcc_address.clone(),
504 subject: draft.subject.clone(),
505 body: draft.body.clone(),
506 project_id: draft.project_id,
507 in_reply_to: draft.in_reply_to.clone(),
508 references: None,
509 thread_id: draft.thread_id.clone(),
510 attachment_paths: Vec::new(),
511 };
512
513 // Send via the existing send_email logic
514 let result = send_email_inner(&state, input).await?;
515
516 // Delete the draft
517 state.emails.delete(id, DESKTOP_USER_ID).await?;
518
519 Ok(result)
520 }
521
522 /// Sends an email via SMTP and saves a copy locally.
523 #[tauri::command]
524 #[instrument(skip_all)]
525 pub async fn send_email(state: State<'_, Arc<AppState>>, input: SendEmailInput) -> Result<SendEmailResponse, ApiError> {
526 send_email_inner(&state, input).await
527 }
528
529 /// Set labels on an email.
530 #[tauri::command]
531 #[instrument(skip_all)]
532 pub async fn set_email_labels(state: State<'_, Arc<AppState>>, id: EmailId, labels: Vec<String>) -> Result<EmailResponse, ApiError> {
533 let email = state.emails
534 .update_labels(id, DESKTOP_USER_ID, &labels)
535 .await?
536 .or_not_found("email", id)?;
537 Ok(EmailResponse::from(email))
538 }
539
540 /// List distinct source folders across all emails.
541 #[tauri::command]
542 #[instrument(skip_all)]
543 pub async fn list_email_folders(state: State<'_, Arc<AppState>>) -> Result<Vec<String>, ApiError> {
544 Ok(state.emails.list_folders(DESKTOP_USER_ID).await?)
545 }
546
547 /// List all distinct labels used across emails.
548 #[tauri::command]
549 #[instrument(skip_all)]
550 pub async fn list_email_labels(state: State<'_, Arc<AppState>>) -> Result<Vec<String>, ApiError> {
551 Ok(state.emails.list_labels(DESKTOP_USER_ID).await?)
552 }
553
554 /// Move an email to a different IMAP folder.
555 #[tauri::command]
556 #[instrument(skip_all)]
557 pub async fn move_email_to_folder(
558 state: State<'_, Arc<AppState>>,
559 id: EmailId,
560 folder: String,
561 ) -> Result<bool, ApiError> {
562 let email = state.emails
563 .get_by_id(id, DESKTOP_USER_ID)
564 .await?
565 .or_not_found("email", id)?;
566
567 let current_folder = email.source_folder.as_deref().unwrap_or("INBOX");
568
569 // Attempt IMAP move if the email has an account and UID
570 if let (Some(account_id), Some(uid)) = (email.email_account_id, email.imap_uid)
571 && let Some((imap_client, _)) = build_imap_client(&state, account_id, "move_to_folder").await
572 && let Err(e) = imap_client.move_message(uid as u32, current_folder, &folder).await {
573 warn!("IMAP move failed (local update will proceed): {}", e);
574 }
575
576 // Always update locally
577 Ok(state.emails.update_source_folder(id, DESKTOP_USER_ID, &folder).await?)
578 }
579
580 /// Deletes an email.
581 #[tauri::command]
582 #[instrument(skip_all)]
583 pub async fn delete_email(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<bool, ApiError> {
584 Ok(state.emails.delete(id, DESKTOP_USER_ID).await?)
585 }
586
587 /// Marks an email as read.
588 #[tauri::command]
589 #[instrument(skip_all)]
590 pub async fn mark_email_read(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<bool, ApiError> {
591 Ok(state.emails.mark_read(id, DESKTOP_USER_ID).await?)
592 }
593
594 /// Marks an email as unread.
595 #[tauri::command]
596 #[instrument(skip_all)]
597 pub async fn mark_email_unread(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<bool, ApiError> {
598 Ok(state.emails.mark_unread(id, DESKTOP_USER_ID).await?)
599 }
600
601 /// Archives an email locally and on the IMAP server.
602 ///
603 /// If the email has an associated IMAP account and UID, attempts to move
604 /// the message to the server's Archive folder via IMAP MOVE. If the IMAP
605 /// move fails (e.g. server unreachable), the local archive still proceeds
606 /// — the next sync will reconcile the mismatch.
607 #[tauri::command]
608 #[instrument(skip_all)]
609 pub async fn archive_email(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<bool, ApiError> {
610 let email = state.emails
611 .get_by_id(id, DESKTOP_USER_ID)
612 .await?
613 .or_not_found("email", id)?;
614
615 if let (Some(account_id), Some(imap_uid)) = (email.email_account_id, email.imap_uid)
616 && let Some((imap_client, archive_folder)) = build_imap_client(&state, account_id, "archive").await
617 && let Err(e) = imap_client.archive_message(imap_uid as u32, &archive_folder).await {
618 warn!("Failed to archive on IMAP server: {}", e);
619 }
620
621 Ok(state.emails.archive(id, DESKTOP_USER_ID).await?)
622 }
623
624 /// Unarchives an email locally and moves it back to INBOX on the IMAP server.
625 ///
626 /// Same best-effort IMAP sync as `archive_email` — local unarchive always
627 /// succeeds even if the server operation fails.
628 #[tauri::command]
629 #[instrument(skip_all)]
630 pub async fn unarchive_email(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<bool, ApiError> {
631 let email = state.emails
632 .get_by_id(id, DESKTOP_USER_ID)
633 .await?
634 .or_not_found("email", id)?;
635
636 if let (Some(account_id), Some(imap_uid)) = (email.email_account_id, email.imap_uid)
637 && let Some((imap_client, archive_folder)) = build_imap_client(&state, account_id, "unarchive").await
638 && let Err(e) = imap_client.unarchive_message(imap_uid as u32, &archive_folder).await {
639 warn!("Failed to unarchive on IMAP server: {}", e);
640 }
641
642 Ok(state.emails.unarchive(id, DESKTOP_USER_ID).await?)
643 }
644
645 /// Marks all emails as read.
646 #[tauri::command]
647 #[instrument(skip_all)]
648 pub async fn mark_all_emails_read(state: State<'_, Arc<AppState>>) -> Result<u64, ApiError> {
649 Ok(state.emails.mark_all_read(DESKTOP_USER_ID).await?)
650 }
651
652 /// Links an email to a project.
653 #[tauri::command]
654 #[instrument(skip_all)]
655 pub async fn link_email_to_project(state: State<'_, Arc<AppState>>, id: EmailId, input: LinkProjectInput) -> Result<bool, ApiError> {
656 Ok(state.emails.link_to_project(id, DESKTOP_USER_ID, input.project_id).await?)
657 }
658
659 /// Gets the count of unread emails.
660 #[tauri::command]
661 #[instrument(skip_all)]
662 pub async fn get_unread_email_count(state: State<'_, Arc<AppState>>) -> Result<UnreadCountResponse, ApiError> {
663 let count = state.emails.count_unread(DESKTOP_USER_ID).await?;
664 Ok(UnreadCountResponse { count })
665 }
666
667 /// Lists all snoozed emails.
668 #[tauri::command]
669 #[instrument(skip_all)]
670 pub async fn list_snoozed_emails(state: State<'_, Arc<AppState>>) -> Result<Vec<EmailResponse>, ApiError> {
671 let emails = state.emails.list_snoozed(DESKTOP_USER_ID).await?;
672 Ok(emails.into_iter().map(EmailResponse::from).collect())
673 }
674
675 /// Snoozes an email until a specified time.
676 #[tauri::command]
677 #[instrument(skip_all)]
678 pub async fn snooze_email(state: State<'_, Arc<AppState>>, id: EmailId, input: SnoozeInput) -> Result<EmailResponse, ApiError> {
679 let email = state.emails
680 .snooze(id, DESKTOP_USER_ID, input.until)
681 .await?
682 .or_not_found("email", id)?;
683 Ok(EmailResponse::from(email))
684 }
685
686 /// Unsnoozes an email.
687 #[tauri::command]
688 #[instrument(skip_all)]
689 pub async fn unsnooze_email(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<EmailResponse, ApiError> {
690 let email = state.emails
691 .unsnooze(id, DESKTOP_USER_ID)
692 .await?
693 .or_not_found("email", id)?;
694 Ok(EmailResponse::from(email))
695 }
696
697 /// Lists all emails marked as waiting for response.
698 #[tauri::command]
699 #[instrument(skip_all)]
700 pub async fn list_waiting_emails(state: State<'_, Arc<AppState>>) -> Result<Vec<EmailResponse>, ApiError> {
701 let emails = state.emails.list_waiting(DESKTOP_USER_ID).await?;
702 Ok(emails.into_iter().map(EmailResponse::from).collect())
703 }
704
705 /// Marks an email as waiting for response.
706 #[tauri::command]
707 #[instrument(skip_all)]
708 pub async fn mark_email_waiting(state: State<'_, Arc<AppState>>, id: EmailId, input: WaitingInput) -> Result<EmailResponse, ApiError> {
709 let email = state.emails
710 .mark_waiting(id, DESKTOP_USER_ID, input.expected_response_date)
711 .await?
712 .or_not_found("email", id)?;
713 Ok(EmailResponse::from(email))
714 }
715
716 /// Clears the waiting status from an email.
717 #[tauri::command]
718 #[instrument(skip_all)]
719 pub async fn clear_email_waiting(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<EmailResponse, ApiError> {
720 let email = state.emails
721 .clear_waiting(id, DESKTOP_USER_ID)
722 .await?
723 .or_not_found("email", id)?;
724 Ok(EmailResponse::from(email))
725 }
726
727 // ============ Project Dashboard Commands ============
728
729 /// Lists all emails for a specific project.
730 #[tauri::command]
731 #[instrument(skip_all)]
732 pub async fn list_emails_for_project(state: State<'_, Arc<AppState>>, project_id: ProjectId) -> Result<Vec<EmailResponse>, ApiError> {
733 let emails = state.emails.list_by_project(DESKTOP_USER_ID, project_id).await?;
734 Ok(emails.into_iter().map(EmailResponse::from).collect())
735 }
736
737 /// Lists emails not linked to any project.
738 #[tauri::command]
739 #[instrument(skip_all)]
740 pub async fn list_unlinked_emails(state: State<'_, Arc<AppState>>) -> Result<Vec<EmailResponse>, ApiError> {
741 let emails = state.emails.list_unlinked(DESKTOP_USER_ID).await?;
742 Ok(emails.into_iter().map(EmailResponse::from).collect())
743 }
744
745 /// Lists all emails in a thread.
746 #[tauri::command]
747 #[instrument(skip_all)]
748 pub async fn list_emails_by_thread(state: State<'_, Arc<AppState>>, thread_id: String) -> Result<Vec<EmailResponse>, ApiError> {
749 let emails = state.emails.list_by_thread(DESKTOP_USER_ID, &thread_id).await?;
750 Ok(emails.into_iter().map(EmailResponse::from).collect())
751 }
752