Skip to main content

max / goingson

17.4 KB · 517 lines History Blame Raw
1 //! Email account management commands.
2 //!
3 //! Provides CRUD operations for email account configurations (IMAP/SMTP/JMAP/OAuth).
4 //! Auth helpers (`get_account_password`, `get_valid_access_token`, `uses_oauth_imap`)
5 //! are `pub(super)` so the sync and email modules can use them.
6
7 use chrono::{Local, Utc};
8 use serde::{Deserialize, Serialize};
9 use std::sync::Arc;
10 use tauri::State;
11 use uuid::Uuid;
12
13 use tracing::instrument;
14
15 use goingson_core::{EmailAccount, EmailAccountId, EmailAuthType};
16
17 use crate::email::{uses_jmap, ImapClient, SmtpClient};
18 use crate::jmap::JmapClient;
19 use crate::oauth::{CredentialStore, TokenManager};
20 use crate::state::{AppState, DESKTOP_USER_ID};
21 use goingson_db_sqlite::utils::is_valid_email;
22 use super::{ApiError, OptionApiError, OptionNotFound, ResultApiError};
23
24 // ============ Auth Helpers (pub(super)) ============
25
26 /// Returns true if the account uses OAuth with IMAP (not JMAP).
27 pub(super) fn uses_oauth_imap(account: &EmailAccount) -> bool {
28 account.is_oauth() && !uses_jmap(account)
29 }
30
31 /// Gets the password for a password-based email account.
32 pub(super) fn get_account_password(account: &EmailAccount) -> Result<String, ApiError> {
33 if !account.password.is_empty() {
34 return Ok(account.password.clone());
35 }
36
37 // Migration: check keychain for passwords stored by older versions
38 let raw_id: Uuid = account.id.into();
39 if let Some(password) = CredentialStore::get_password(raw_id) {
40 return Ok(password);
41 }
42
43 Err(ApiError::auth("No password available"))
44 }
45
46 /// Gets a valid access token for an OAuth account, refreshing if needed.
47 pub(super) async fn get_valid_access_token(
48 state: &Arc<AppState>,
49 account: &EmailAccount,
50 ) -> Result<String, ApiError> {
51 let raw_id: Uuid = account.id.into();
52 // Get access token from keychain (fall back to database for migration)
53 let access_token = CredentialStore::get_oauth(raw_id)
54 .map(|c| c.access_token)
55 .or_else(|| account.oauth2_access_token.clone())
56 .or_api_err(|| ApiError::auth("No access token available"))?;
57
58 // Check if token needs refresh (serialized per-account to prevent thundering herd)
59 if account.needs_token_refresh() {
60 let refresh_lock = state.token_refresh_lock(raw_id);
61 let _guard = refresh_lock.lock().await;
62 let token_manager = TokenManager::from_env();
63 match token_manager.refresh_if_needed(account).await
64 .map_api_err("Token refresh failed", ApiError::auth)?
65 {
66 Some((new_access_token, new_refresh_token, expires_at)) => {
67 // Update expiration in database (not tokens)
68 state
69 .email_accounts
70 .update_oauth_tokens(
71 account.id,
72 DESKTOP_USER_ID,
73 "", // Don't store in DB
74 None,
75 expires_at,
76 )
77 .await?;
78
79 // Store new tokens in keychain
80 CredentialStore::update_oauth_tokens(
81 raw_id,
82 &new_access_token,
83 new_refresh_token.as_deref(),
84 )
85 .map_api_err("Failed to store tokens", ApiError::internal)?;
86
87 Ok(new_access_token)
88 }
89 None => Ok(access_token),
90 }
91 } else {
92 Ok(access_token)
93 }
94 }
95
96 // ============ Types ============
97
98 /// Email account response with pre-computed fields for UI.
99 #[derive(Debug, Serialize)]
100 #[serde(rename_all = "camelCase")]
101 pub struct EmailAccountResponse {
102 pub id: EmailAccountId,
103 pub account_name: String,
104 pub email_address: String,
105 pub imap_server: String,
106 pub imap_port: i32,
107 pub smtp_server: String,
108 pub smtp_port: i32,
109 pub username: String,
110 pub use_tls: bool,
111 pub last_sync_at: Option<chrono::DateTime<Utc>>,
112 pub created_at: chrono::DateTime<Utc>,
113 pub archive_folder_name: Option<String>,
114 pub auth_type: EmailAuthType,
115 pub oauth2_token_expires_at: Option<chrono::DateTime<Utc>>,
116 pub sync_interval_minutes: Option<i32>,
117 pub email_signature: Option<String>,
118 pub notify_new_emails: bool,
119 // Pre-computed fields
120 /// Human-readable last sync time: "Just now", "5m ago", "2h ago", "Never synced"
121 pub last_sync_formatted: String,
122 }
123
124 impl From<EmailAccount> for EmailAccountResponse {
125 fn from(a: EmailAccount) -> Self {
126 let last_sync_formatted = match a.last_sync_at {
127 None => "Never synced".to_string(),
128 Some(sync_at) => {
129 let now = Utc::now();
130 let diff = now.signed_duration_since(sync_at);
131 let mins = diff.num_minutes();
132 if mins < 1 {
133 "Just now".to_string()
134 } else if mins < 60 {
135 format!("{}m ago", mins)
136 } else {
137 let hours = diff.num_hours();
138 if hours < 24 {
139 format!("{}h ago", hours)
140 } else {
141 sync_at.with_timezone(&Local).format("%b %d").to_string()
142 }
143 }
144 }
145 };
146
147 EmailAccountResponse {
148 id: a.id,
149 account_name: a.account_name,
150 email_address: a.email_address,
151 imap_server: a.imap_server,
152 imap_port: a.imap_port,
153 smtp_server: a.smtp_server,
154 smtp_port: a.smtp_port,
155 username: a.username,
156 use_tls: a.use_tls,
157 last_sync_at: a.last_sync_at,
158 created_at: a.created_at,
159 archive_folder_name: a.archive_folder_name,
160 auth_type: a.auth_type,
161 oauth2_token_expires_at: a.oauth2_token_expires_at,
162 sync_interval_minutes: a.sync_interval_minutes,
163 email_signature: a.email_signature,
164 notify_new_emails: a.notify_new_emails,
165 last_sync_formatted,
166 }
167 }
168 }
169
170 #[derive(Debug, Deserialize)]
171 #[serde(rename_all = "camelCase")]
172 pub struct EmailAccountInput {
173 pub account_name: String,
174 pub email_address: String,
175 pub imap_server: String,
176 pub imap_port: i32,
177 pub smtp_server: String,
178 pub smtp_port: i32,
179 pub username: String,
180 pub password: String,
181 pub use_tls: bool,
182 pub archive_folder_name: Option<String>,
183 pub sync_interval_minutes: Option<i32>,
184 }
185
186 #[derive(Debug, Deserialize)]
187 #[serde(rename_all = "camelCase")]
188 pub struct EmailAccountUpdateInput {
189 pub account_name: String,
190 pub email_address: String,
191 pub imap_server: String,
192 pub imap_port: i32,
193 pub smtp_server: String,
194 pub smtp_port: i32,
195 pub username: String,
196 pub password: Option<String>,
197 pub use_tls: bool,
198 pub archive_folder_name: Option<String>,
199 pub sync_interval_minutes: Option<i32>,
200 }
201
202 #[derive(Debug, Deserialize)]
203 #[serde(rename_all = "camelCase")]
204 pub struct SyncIntervalInput {
205 pub sync_interval_minutes: Option<i32>,
206 }
207
208 #[derive(Debug, Deserialize)]
209 #[serde(rename_all = "camelCase")]
210 pub struct SignatureInput {
211 pub email_signature: Option<String>,
212 }
213
214 #[derive(Debug, Serialize)]
215 #[serde(rename_all = "camelCase")]
216 pub struct TestConnectionResponse {
217 pub imap_success: bool,
218 pub imap_message: String,
219 pub smtp_success: bool,
220 pub smtp_message: String,
221 pub available_folders: Vec<String>,
222 }
223
224 // ============ Commands ============
225
226 /// Lists all email accounts for the current user.
227 #[tauri::command]
228 #[instrument(skip_all)]
229 pub async fn list_email_accounts(state: State<'_, Arc<AppState>>) -> Result<Vec<EmailAccountResponse>, ApiError> {
230 let accounts = state.email_accounts.list_by_user(DESKTOP_USER_ID).await?;
231 Ok(accounts.into_iter().map(EmailAccountResponse::from).collect())
232 }
233
234 /// Retrieves a single email account by ID.
235 #[tauri::command]
236 #[instrument(skip_all)]
237 pub async fn get_email_account(state: State<'_, Arc<AppState>>, id: EmailAccountId) -> Result<Option<EmailAccount>, ApiError> {
238 Ok(state.email_accounts.get_by_id(id, DESKTOP_USER_ID).await?)
239 }
240
241 /// Creates a new email account with IMAP/SMTP credentials.
242 #[tauri::command]
243 #[instrument(skip_all)]
244 pub async fn create_email_account(state: State<'_, Arc<AppState>>, input: EmailAccountInput) -> Result<EmailAccount, ApiError> {
245 if input.account_name.trim().is_empty() {
246 return Err(ApiError::validation("accountName", "Account name is required"));
247 }
248 if !is_valid_email(&input.email_address) {
249 return Err(ApiError::validation("emailAddress", "Invalid email address"));
250 }
251 if input.imap_server.trim().is_empty() {
252 return Err(ApiError::validation("imapServer", "IMAP server is required"));
253 }
254 if input.smtp_server.trim().is_empty() {
255 return Err(ApiError::validation("smtpServer", "SMTP server is required"));
256 }
257
258 if let Some(ref folder) = input.archive_folder_name {
259 if folder.contains('\r') || folder.contains('\n') || folder.chars().any(|c| c.is_control()) {
260 return Err(ApiError::validation("archiveFolderName", "Folder name contains invalid characters"));
261 }
262 }
263
264 let account = state.email_accounts
265 .create(
266 DESKTOP_USER_ID,
267 &input.account_name,
268 &input.email_address,
269 &input.imap_server,
270 input.imap_port,
271 &input.smtp_server,
272 input.smtp_port,
273 &input.username,
274 &input.password,
275 input.use_tls,
276 input.archive_folder_name.as_deref(),
277 )
278 .await?;
279
280 Ok(account)
281 }
282
283 /// Updates an existing email account.
284 #[tauri::command]
285 #[instrument(skip_all)]
286 pub async fn update_email_account(state: State<'_, Arc<AppState>>, id: EmailAccountId, input: EmailAccountUpdateInput) -> Result<EmailAccount, ApiError> {
287 if input.account_name.trim().is_empty() {
288 return Err(ApiError::validation("accountName", "Account name is required"));
289 }
290 if !is_valid_email(&input.email_address) {
291 return Err(ApiError::validation("emailAddress", "Invalid email address"));
292 }
293
294 if let Some(ref folder) = input.archive_folder_name {
295 if folder.contains('\r') || folder.contains('\n') || folder.chars().any(|c| c.is_control()) {
296 return Err(ApiError::validation("archiveFolderName", "Folder name contains invalid characters"));
297 }
298 }
299
300 state.email_accounts
301 .update(
302 id,
303 DESKTOP_USER_ID,
304 &input.account_name,
305 &input.email_address,
306 &input.imap_server,
307 input.imap_port,
308 &input.smtp_server,
309 input.smtp_port,
310 &input.username,
311 input.password.as_deref(),
312 input.use_tls,
313 input.archive_folder_name.as_deref(),
314 )
315 .await?
316 .or_not_found("emailAccount", id)
317 }
318
319 /// Deletes an email account. Also removes credentials from OS keychain.
320 #[tauri::command]
321 #[instrument(skip_all)]
322 pub async fn delete_email_account(state: State<'_, Arc<AppState>>, id: EmailAccountId) -> Result<bool, ApiError> {
323 let raw_id: Uuid = id.into();
324 let _ = CredentialStore::delete_oauth(raw_id);
325 Ok(state.email_accounts.delete(id, DESKTOP_USER_ID).await?)
326 }
327
328 /// Updates the sync interval for an email account.
329 #[tauri::command]
330 #[instrument(skip_all)]
331 pub async fn update_email_sync_interval(
332 state: State<'_, Arc<AppState>>,
333 id: EmailAccountId,
334 input: SyncIntervalInput,
335 ) -> Result<EmailAccount, ApiError> {
336 state.email_accounts
337 .update_sync_interval(id, DESKTOP_USER_ID, input.sync_interval_minutes)
338 .await?
339 .or_not_found("emailAccount", id)
340 }
341
342 /// Updates the email signature for an account.
343 #[tauri::command]
344 #[instrument(skip_all)]
345 pub async fn update_email_signature(
346 state: State<'_, Arc<AppState>>,
347 id: EmailAccountId,
348 input: SignatureInput,
349 ) -> Result<EmailAccountResponse, ApiError> {
350 let sig = input.email_signature.filter(|s| !s.trim().is_empty());
351 let account = state.email_accounts
352 .update_signature(id, DESKTOP_USER_ID, sig.as_deref())
353 .await?
354 .or_not_found("emailAccount", id)?;
355 Ok(account.into())
356 }
357
358 /// Updates the notification preference for an email account.
359 #[tauri::command]
360 #[instrument(skip_all)]
361 pub async fn update_email_notify(
362 state: State<'_, Arc<AppState>>,
363 id: EmailAccountId,
364 enabled: bool,
365 ) -> Result<EmailAccountResponse, ApiError> {
366 let account = state.email_accounts
367 .update_notify_new_emails(id, DESKTOP_USER_ID, enabled)
368 .await?
369 .or_not_found("emailAccount", id)?;
370 Ok(account.into())
371 }
372
373 /// Tests an email account's IMAP and SMTP connections.
374 #[tauri::command]
375 #[instrument(skip_all)]
376 pub async fn test_email_account(state: State<'_, Arc<AppState>>, id: EmailAccountId) -> Result<TestConnectionResponse, ApiError> {
377 let account = state.email_accounts
378 .get_by_id(id, DESKTOP_USER_ID)
379 .await?
380 .or_not_found("emailAccount", id)?;
381
382 // Handle OAuth/JMAP accounts differently
383 if uses_jmap(&account) {
384 return test_jmap_account(&account).await;
385 }
386
387 if uses_oauth_imap(&account) {
388 return test_oauth_imap_account(&state, &account).await;
389 }
390
391 // Password-based IMAP/SMTP
392 let password = get_account_password(&account)?;
393
394 let imap_client = ImapClient::with_password(&account, &password);
395 let (imap_success, imap_message, available_folders) = match imap_client.test_connection().await {
396 Ok(()) => {
397 let folders = imap_client.list_folders().await.unwrap_or_default();
398 (true, "IMAP connection successful".to_string(), folders)
399 }
400 Err(e) => (false, format!("IMAP error: {}", e), Vec::new()),
401 };
402
403 let smtp_client = SmtpClient::with_password(&account, &password);
404 let (smtp_success, smtp_message) = match smtp_client.test_connection().await {
405 Ok(()) => (true, "SMTP connection successful".to_string()),
406 Err(e) => (false, format!("SMTP error: {}", e)),
407 };
408
409 Ok(TestConnectionResponse {
410 imap_success,
411 imap_message,
412 smtp_success,
413 smtp_message,
414 available_folders,
415 })
416 }
417
418 /// Tests an OAuth IMAP account connection using XOAUTH2.
419 async fn test_oauth_imap_account(
420 state: &Arc<AppState>,
421 account: &EmailAccount,
422 ) -> Result<TestConnectionResponse, ApiError> {
423 let access_token = get_valid_access_token(state, account).await?;
424
425 let imap_client = ImapClient::with_oauth(
426 &account.imap_server,
427 account.imap_port as u16,
428 &account.email_address,
429 &access_token,
430 );
431
432 let (imap_success, imap_message, available_folders) = match imap_client.test_connection().await {
433 Ok(()) => {
434 let folders = imap_client.list_folders().await.unwrap_or_default();
435 (true, "IMAP XOAUTH2 connection successful".to_string(), folders)
436 }
437 Err(e) => {
438 let is_auth_error = e.contains("AUTH") || e.contains("auth") || e.contains("AUTHENTICATE");
439 let message = if is_auth_error {
440 "XOAUTH2 authentication failed - please reconnect your account".to_string()
441 } else {
442 format!("IMAP error: {}", e)
443 };
444 (false, message, Vec::new())
445 }
446 };
447
448 let smtp_client = SmtpClient::with_oauth(
449 &account.smtp_server,
450 account.smtp_port as u16,
451 &account.email_address,
452 &access_token,
453 true,
454 );
455
456 let (smtp_success, smtp_message) = match smtp_client.test_connection().await {
457 Ok(()) => (true, "SMTP XOAUTH2 connection successful".to_string()),
458 Err(e) => (false, format!("SMTP error: {}", e)),
459 };
460
461 Ok(TestConnectionResponse {
462 imap_success,
463 imap_message,
464 smtp_success,
465 smtp_message,
466 available_folders,
467 })
468 }
469
470 /// Tests a JMAP account connection.
471 async fn test_jmap_account(account: &EmailAccount) -> Result<TestConnectionResponse, ApiError> {
472 let session_url = account.jmap_session_url.as_ref()
473 .or_api_err(|| ApiError::bad_request("No JMAP session URL configured"))?;
474
475 let access_token = CredentialStore::get_oauth(account.id.into())
476 .map(|c| c.access_token)
477 .or_else(|| account.oauth2_access_token.clone())
478 .or_api_err(|| ApiError::auth("No access token available"))?;
479
480 let mut client = JmapClient::new(session_url, &access_token)
481 .map_err(ApiError::external_service)?;
482
483 let session_result = client.session().await;
484 let username = match &session_result {
485 Ok(session) => session.username.clone(),
486 Err(e) => {
487 let is_auth_error = e.contains("401") || e.contains("unauthorized") || e.contains("Unauthorized");
488 let message = if is_auth_error {
489 "Authentication failed - please reconnect your account".to_string()
490 } else {
491 format!("JMAP error: {}", e)
492 };
493
494 return Ok(TestConnectionResponse {
495 imap_success: false,
496 imap_message: message,
497 smtp_success: false,
498 smtp_message: "Cannot test - session failed".to_string(),
499 available_folders: Vec::new(),
500 });
501 }
502 };
503
504 let folders = match client.list_mailboxes().await {
505 Ok(mailboxes) => mailboxes.into_iter().map(|m| m.name).collect(),
506 Err(_) => Vec::new(),
507 };
508
509 Ok(TestConnectionResponse {
510 imap_success: true,
511 imap_message: format!("JMAP session OK - connected as {}", username),
512 smtp_success: true,
513 smtp_message: "JMAP Submission available".to_string(),
514 available_folders: folders,
515 })
516 }
517