Skip to main content

max / goingson

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