git.lucas.co / cce-mail
mail client (IMAP/SMTP)
git clone https://git.lucas.co/cce-mail.git

commite1cfe20e58c8b85ed581e8f8f62acbc8f3b11002
parente6119652ff
authorLucas Galante <[email protected]>
date2026-07-27 10:10
feat: server-side deletes — UID plumbing + shared session opener

- Email rows carry the server UID (serde-default None for mock/local mail);
  pass-1 fetch adds UID. Server ops key on UID, never on id — id is the
  fetch-time sequence number, which shifts after any expunge
- open_imap_session(): the connect/oauth/login block extracted from
  sync_imap, shared with the new delete_on_server() worker
- deleting an inbox mail now best-effort-copies to [Gmail]/Trash or Trash,
  then UID STOREs \Deleted and uid_expunge()s (EXPUNGE fallback); local
  move to trash stays immediate, server result arrives as a toast

Live-verified on Gmail: 'Deleted on server' toast, and a forced fresh sync
no longer resurrects the message.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/main.rs | 211 ++++++++++++++++++++++++++++++++++++++++--------------------
 1 file changed, 143 insertions(+), 68 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 811157a..ea23e3b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -51,6 +51,11 @@ struct Email {
     date: String,
     read: bool,
     folder: String, // "inbox", "sent", "trash"
+    /// Server-side IMAP UID (INBOX message); None for mock or locally-created
+    /// mail. Server operations (delete) key on this, never on `id` — `id` is
+    /// the fetch-time sequence number, which shifts after any expunge.
+    #[serde(default)]
+    uid: Option<u32>,
 }
 
 #[derive(Debug, Clone)]
@@ -575,82 +580,97 @@ fn block_on_worker<F: std::future::Future>(fut: F) -> Result<F::Output, String>
         .map(|rt| rt.block_on(fut))
 }
 
-fn sync_imap(mut account: AccountInfo, sender: calloop::channel::Sender<AppMessage>) {
-    std::thread::spawn(move || {
-        // Skip connecting for mock credentials
-        if account.password == "mock_password" || account.email == "[email protected]" {
-            return;
-        }
+/// True for the built-in demo account — workers skip the network entirely.
+fn is_mock_account(account: &AccountInfo) -> bool {
+    account.password == "mock_password" || account.email == "[email protected]"
+}
 
-        let mut access_token = account.password.clone();
-        if account.is_oauth {
-            let mut acc = account.clone();
-            match block_on_worker(refresh_access_token(&mut acc)).and_then(|r| r) {
-                Ok(token) => {
-                    access_token = token;
-                    // Send refreshed tokens back to main thread to save them
-                    let _ = sender.send(AppMessage::UpdateAccountTokens(
-                        acc.email.clone(),
-                        acc.access_token.clone(),
-                        acc.token_expiry,
-                    ));
-                    account.access_token = acc.access_token;
-                    account.token_expiry = acc.token_expiry;
-                }
-                Err(e) => {
-                    let _ = sender.send(AppMessage::Status(format!("OAuth Refresh Failed: {}", e)));
-                    return;
-                }
+/// Connect + authenticate an IMAP session; shared by the sync and
+/// server-delete workers (call from a worker thread — it blocks). Refreshed
+/// OAuth tokens are reported back via UpdateAccountTokens; every failure
+/// surfaces as a Status toast and yields None.
+fn open_imap_session(
+    account: &mut AccountInfo,
+    sender: &calloop::channel::Sender<AppMessage>,
+) -> Option<imap::Session<native_tls::TlsStream<std::net::TcpStream>>> {
+    let mut access_token = account.password.clone();
+    if account.is_oauth {
+        let mut acc = account.clone();
+        match block_on_worker(refresh_access_token(&mut acc)).and_then(|r| r) {
+            Ok(token) => {
+                access_token = token;
+                // Send refreshed tokens back to main thread to save them
+                let _ = sender.send(AppMessage::UpdateAccountTokens(
+                    acc.email.clone(),
+                    acc.access_token.clone(),
+                    acc.token_expiry,
+                ));
+                account.access_token = acc.access_token;
+                account.token_expiry = acc.token_expiry;
+            }
+            Err(e) => {
+                let _ = sender.send(AppMessage::Status(format!("OAuth Refresh Failed: {}", e)));
+                return None;
             }
         }
+    }
 
-        let domain = match account.imap.split(':').next() {
-            Some(d) => d,
-            None => return,
-        };
-        let port = match account.imap.split(':').nth(1).and_then(|p| p.parse::<u16>().ok()) {
-            Some(p) => p,
-            None => 993,
-        };
+    let domain = account.imap.split(':').next()?;
+    let port = account
+        .imap
+        .split(':')
+        .nth(1)
+        .and_then(|p| p.parse::<u16>().ok())
+        .unwrap_or(993);
 
-        let _ = sender.send(AppMessage::Status(format!("Connecting to {}...", account.imap)));
-        
-        let tls = match TlsConnector::new() {
-            Ok(t) => t,
-            Err(_) => {
-                let _ = sender.send(AppMessage::Status("Failed to create TLS connector".to_string()));
-                return;
-            }
-        };
+    let _ = sender.send(AppMessage::Status(format!("Connecting to {}...", account.imap)));
 
-        let client = match imap::connect((domain, port), domain, &tls) {
-            Ok(c) => c,
-            Err(e) => {
-                let _ = sender.send(AppMessage::Status(format!("IMAP Connection failed: {}", e)));
-                return;
-            }
-        };
+    let tls = match TlsConnector::new() {
+        Ok(t) => t,
+        Err(_) => {
+            let _ = sender.send(AppMessage::Status("Failed to create TLS connector".to_string()));
+            return None;
+        }
+    };
 
-        let mut session = if account.is_oauth {
-            let auth = ImapOAuth2 {
-                user: account.email.clone(),
-                access_token: access_token.clone(),
-            };
-            match client.authenticate("XOAUTH2", &auth) {
-                Ok(s) => s,
-                Err((e, _)) => {
-                    let _ = sender.send(AppMessage::Status(format!("IMAP OAuth Login failed: {}", e)));
-                    return;
-                }
+    let client = match imap::connect((domain, port), domain, &tls) {
+        Ok(c) => c,
+        Err(e) => {
+            let _ = sender.send(AppMessage::Status(format!("IMAP Connection failed: {}", e)));
+            return None;
+        }
+    };
+
+    if account.is_oauth {
+        let auth = ImapOAuth2 {
+            user: account.email.clone(),
+            access_token,
+        };
+        match client.authenticate("XOAUTH2", &auth) {
+            Ok(s) => Some(s),
+            Err((e, _)) => {
+                let _ = sender.send(AppMessage::Status(format!("IMAP OAuth Login failed: {}", e)));
+                None
             }
-        } else {
-            match client.login(&account.email, &account.password) {
-                Ok(s) => s,
-                Err((e, _)) => {
-                    let _ = sender.send(AppMessage::Status(format!("IMAP Login failed: {}", e)));
-                    return;
-                }
+        }
+    } else {
+        match client.login(&account.email, &account.password) {
+            Ok(s) => Some(s),
+            Err((e, _)) => {
+                let _ = sender.send(AppMessage::Status(format!("IMAP Login failed: {}", e)));
+                None
             }
+        }
+    }
+}
+
+fn sync_imap(mut account: AccountInfo, sender: calloop::channel::Sender<AppMessage>) {
+    std::thread::spawn(move || {
+        if is_mock_account(&account) {
+            return;
+        }
+        let Some(mut session) = open_imap_session(&mut account, &sender) else {
+            return;
         };
 
         let _ = sender.send(AppMessage::Status("Syncing Inbox...".to_string()));
@@ -688,6 +708,7 @@ fn sync_imap(mut account: AccountInfo, sender: calloop::channel::Sender<AppMessa
 
         struct Pending {
             seq: u32,
+            uid: Option<u32>,
             read: bool,
             from: String,
             subject: String,
@@ -697,7 +718,7 @@ fn sync_imap(mut account: AccountInfo, sender: calloop::channel::Sender<AppMessa
         }
         let parser = mail_parser::MessageParser::default();
         let mut pending: Vec<Pending> = Vec::new();
-        match session.fetch(&query_seq, "(FLAGS RFC822.HEADER BODYSTRUCTURE)") {
+        match session.fetch(&query_seq, "(UID FLAGS RFC822.HEADER BODYSTRUCTURE)") {
             Ok(fetches) => {
                 for fetch in fetches.iter() {
                     let read = fetch
@@ -718,6 +739,7 @@ fn sync_imap(mut account: AccountInfo, sender: calloop::channel::Sender<AppMessa
                     };
                     pending.push(Pending {
                         seq: fetch.message,
+                        uid: fetch.uid,
                         read,
                         from,
                         subject,
@@ -793,6 +815,7 @@ fn sync_imap(mut account: AccountInfo, sender: calloop::channel::Sender<AppMessa
             .into_iter()
             .map(|p| Email {
                 id: p.seq as usize,
+                uid: p.uid,
                 from: p.from,
                 to: account.email.clone(),
                 subject: p.subject,
@@ -811,6 +834,44 @@ fn sync_imap(mut account: AccountInfo, sender: calloop::channel::Sender<AppMessa
     });
 }
 
+/// Delete an INBOX message on the server, by UID. Best-effort trash-copy
+/// first: on Gmail a copy to [Gmail]/Trash removes the message everywhere,
+/// on generic servers a "Trash" copy keeps the conventional safety net; the
+/// STORE+expunge below is what actually removes it from INBOX either way.
+/// UIDPLUS uid_expunge targets just our message; plain EXPUNGE fallback also
+/// flushes any other \Deleted-flagged mail, which matches client convention.
+fn delete_on_server(mut account: AccountInfo, uid: u32, sender: calloop::channel::Sender<AppMessage>) {
+    std::thread::spawn(move || {
+        if is_mock_account(&account) {
+            return;
+        }
+        let Some(mut session) = open_imap_session(&mut account, &sender) else {
+            return;
+        };
+        if let Err(e) = session.select("INBOX") {
+            let _ = sender.send(AppMessage::Status(format!("Failed to select INBOX: {}", e)));
+            let _ = session.logout();
+            return;
+        }
+        let uid_set = uid.to_string();
+        for trash in ["[Gmail]/Trash", "Trash"] {
+            if session.uid_copy(&uid_set, trash).is_ok() {
+                break;
+            }
+        }
+        if let Err(e) = session.uid_store(&uid_set, "+FLAGS (\\Deleted)") {
+            let _ = sender.send(AppMessage::Status(format!("Server delete failed: {}", e)));
+            let _ = session.logout();
+            return;
+        }
+        if session.uid_expunge(&uid_set).is_err() {
+            let _ = session.expunge();
+        }
+        let _ = sender.send(AppMessage::Status("Deleted on server".to_string()));
+        let _ = session.logout();
+    });
+}
+
 fn send_smtp(mut account: AccountInfo, to: String, subject: String, body: String, sender: calloop::channel::Sender<AppMessage>) {
     std::thread::spawn(move || {
         if account.password == "mock_password" {
@@ -913,6 +974,7 @@ fn get_default_mock_emails() -> Vec<Email> {
             date: "12:15 PM".to_string(),
             read: false,
             folder: "inbox".to_string(),
+            uid: None,
         },
         Email {
             id: 2,
@@ -923,6 +985,7 @@ fn get_default_mock_emails() -> Vec<Email> {
             date: "Yesterday".to_string(),
             read: false,
             folder: "inbox".to_string(),
+            uid: None,
         },
         Email {
             id: 3,
@@ -933,6 +996,7 @@ fn get_default_mock_emails() -> Vec<Email> {
             date: "June 3".to_string(),
             read: true,
             folder: "inbox".to_string(),
+            uid: None,
         },
         Email {
             id: 4,
@@ -943,6 +1007,7 @@ fn get_default_mock_emails() -> Vec<Email> {
             date: "Yesterday".to_string(),
             read: true,
             folder: "sent".to_string(),
+            uid: None,
         },
     ]
 }
@@ -1515,6 +1580,7 @@ impl Application for ClearEmailApp {
                         date: "Just now".to_string(),
                         read: true,
                         folder: "sent".to_string(),
+                        uid: None,
                     };
                     self.emails.push(new_email);
                     self.save_emails();
@@ -1551,13 +1617,22 @@ impl Application for ClearEmailApp {
             AppMessage::DeleteSelected => {
                 if let Some(id) = self.selected_email_id {
                     let mut permanently_deleted = false;
+                    let mut server_uid = None;
                     if let Some(email) = self.emails.iter_mut().find(|e| e.id == id) {
                         if email.folder == "trash" {
                             permanently_deleted = true;
                         } else {
+                            if email.folder == "inbox" {
+                                server_uid = email.uid;
+                            }
                             email.folder = "trash".to_string();
                         }
                     }
+                    if let Some(uid) = server_uid {
+                        if let Some(acc) = self.accounts.get(self.selected_account_idx) {
+                            delete_on_server(acc.clone(), uid, self.sender.clone());
+                        }
+                    }
                     if permanently_deleted {
                         self.emails.retain(|e| e.id != id);
                     }