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

commit1e2b338ffe3a4d9ce8b0b4f1a2f4d803e68cefb2
parentd69f3fcbfa
authorLucas Galante <[email protected]>
date2026-08-22 14:56
fix: re-read the keyring before a sync, and retry until it opens

Secrets resolved exactly once, at startup. The compositor restores every
saved window at once, so cce-mail can read the vault in the same second
KeePassXC is still claiming org.freedesktop.secrets — leaving a
keyring-backed account with an empty password and every sync failing on
the server's "Empty username or password" until the app was restarted.
That is what put a red IMAP Login failed toast on screen this session.

start_sync now re-reads the keyring when the selected account has no
password, and the startup sync goes through start_sync instead of
calling sync_imap inline — it was the one sync that bypassed every
guard, and precisely the one that fails in this race.

A sync that still has no password no longer makes the round trip: it
says so itself ("No password for X — is the keyring unlocked?") rather
than relaying a server reply that reads as an app bug, and arms a 5s
retry so unlocking the vault brings mail in on its own. That also closes
the mid-session re-lock case, which had no recovery path at all. OAuth
accounts are exempt — they carry no password by design.

Reporting is once-per-outage, not once-per-retry, and a failed retry
requests no redraw, so a vault left shut costs one D-Bus call every five
seconds and nothing else.

Shadow-verified by reproducing the race with a throwaway keyring entry
created after startup: one log line while the secret was absent, then
recovery and connect within 5s of it appearing. Test entry removed; the
real Gmail/iCloud entries were only ever existence-checked.

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

 src/main.rs | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++-------
 1 file changed, 104 insertions(+), 13 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 18ae8db..7c1f5d8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -222,6 +222,16 @@ struct ClearEmailApp {
     /// When the most recent IMAP sync was spawned — folder switches re-sync
     /// through [`Self::start_sync`], throttled against tab-hopping.
     last_sync_start: Option<std::time::Instant>,
+    /// Set when a sync was skipped because the account had no password: the
+    /// vault was not readable yet. `tick` retries at this instant, so an
+    /// unlock heals the account on its own rather than waiting for the user
+    /// to go looking for a Sync Now.
+    secret_retry_at: Option<std::time::Instant>,
+    /// Whether the missing password has already been reported. Separate from
+    /// `secret_retry_at`, which `tick` clears before each attempt — reusing
+    /// that as the "already said this" flag made every retry look like the
+    /// first and logged the same line every few seconds.
+    secret_missing_reported: bool,
     status_message: Option<StatusToast>,
     sender: calloop::channel::Sender<AppMessage>,
 
@@ -470,6 +480,11 @@ const DETAIL_W_MIN: f32 = 220.0;
 /// Half-width of the separator's grab band (±, matching ScrollRegion's slop).
 const SPLIT_GRAB_SLOP: f32 = 4.0;
 
+/// How long to wait before re-reading the keyring after a sync found the
+/// account had no password. Short enough that unlocking the vault feels
+/// self-healing, long enough that a vault left locked costs nothing.
+const SECRET_RETRY_SECS: u64 = 5;
+
 /// Row pitch of the toolkit context menu — its own layout constant
 /// (`ContextMenuState`), mirrored here because hit-testing is done app-side.
 const CONTEXT_ROW_H: f32 = 24.0;
@@ -1905,15 +1920,78 @@ impl ClearEmailApp {
     /// Spawn an IMAP sync for the selected account. Unforced calls (folder
     /// switches) are throttled so tab-hopping doesn't stack connections;
     /// forced calls (explicit account selection) always run.
+    /// Re-read a keyring-backed account's password when it has gone missing.
+    ///
+    /// Secrets otherwise resolve exactly once, at startup — and the compositor
+    /// restores every saved window at once, so cce-mail can read the vault in
+    /// the same second KeePassXC is still claiming org.freedesktop.secrets.
+    /// The account is then left holding an empty password and every sync fails
+    /// with the server's `Empty username or password` until the app is
+    /// restarted (diagnosed 2026-08-22 from the mirrored stderr). Re-reading
+    /// before a sync heals that race, and the mid-session re-lock with it.
+    ///
+    /// Deliberately only when the password is MISSING: the resolver's other
+    /// branch writes a non-empty password back INTO the keyring, which has no
+    /// business running on every sync.
+    fn refresh_account_secret(&mut self, idx: usize) {
+        let Some(acc) = self.accounts.get_mut(idx) else {
+            return;
+        };
+        if !acc.password.is_empty() || is_mock_account(acc) {
+            return;
+        }
+        let email = acc.email.clone();
+        let migrated = resolve_account_secrets(std::slice::from_mut(acc));
+        if !self.accounts[idx].password.is_empty() {
+            eprintln!("cce-mail: recovered {} credentials from the keyring", email);
+        }
+        if migrated {
+            save_accounts(&self.accounts);
+        }
+    }
+
     fn start_sync(&mut self, force: bool) {
         const MIN_SYNC_GAP: std::time::Duration = std::time::Duration::from_secs(30);
         if !force && self.last_sync_start.is_some_and(|t| t.elapsed() < MIN_SYNC_GAP) {
             return;
         }
-        if let Some(acc) = self.accounts.get(self.selected_account_idx) {
-            sync_imap(acc.clone(), self.sender.clone());
-            self.last_sync_start = Some(std::time::Instant::now());
+        self.refresh_account_secret(self.selected_account_idx);
+
+        let Some((email, is_oauth, no_password, mock)) = self
+            .accounts
+            .get(self.selected_account_idx)
+            .map(|a| (a.email.clone(), a.is_oauth, a.password.is_empty(), is_mock_account(a)))
+        else {
+            return;
+        };
+
+        // Still nothing to log in with: say what is actually wrong rather than
+        // relaying the server's reply, which reads as a bug in the app. OAuth
+        // accounts legitimately carry no password — the token comes from the
+        // refresh — so they are exempt.
+        if !is_oauth && no_password && !mock {
+            // First miss only. The retry below runs until the vault opens, so
+            // saying this every few seconds would spam the session log for as
+            // long as it stays shut, and keep clobbering whatever toast the
+            // user is actually reading.
+            if !self.secret_missing_reported {
+                self.secret_missing_reported = true;
+                let msg = format!("No password for {} — is the keyring unlocked?", email);
+                eprintln!("cce-mail: {} (retrying every {}s)", msg, SECRET_RETRY_SECS);
+                self.status_message = Some(StatusToast::error(msg));
+            }
+            self.secret_retry_at =
+                Some(std::time::Instant::now() + std::time::Duration::from_secs(SECRET_RETRY_SECS));
+            // last_sync_start is left alone: unlocking the vault should not
+            // then have to wait out the throttle.
+            return;
         }
+
+        self.secret_retry_at = None;
+        self.secret_missing_reported = false;
+        let account = self.accounts[self.selected_account_idx].clone();
+        sync_imap(account, self.sender.clone());
+        self.last_sync_start = Some(std::time::Instant::now());
     }
 
     fn save_emails(&self) {
@@ -2439,15 +2517,10 @@ impl Application for ClearEmailApp {
             Vec::new()
         };
 
-        let last_sync_start = if let Some(acc) = accounts.get(selected_account_idx) {
-            sync_imap(acc.clone(), _sender.clone());
-            Some(std::time::Instant::now())
-        } else {
-            None
-        };
-
-        Self {
-            last_sync_start,
+        let mut app = Self {
+            last_sync_start: None,
+            secret_retry_at: None,
+            secret_missing_reported: false,
             keys: EmailKeys::load(),
             mail_menu,
             folder_dropdown,
@@ -2489,7 +2562,14 @@ impl Application for ClearEmailApp {
             font_system: cce_ui::create_font_system(),
             needs_rebuild: true,
             ui_context: UiContext::new(),
-        }
+        };
+
+        // The first sync goes through start_sync like every other one, so it
+        // gets the same keyring refresh and missing-password guard. It used to
+        // call sync_imap inline here — the one sync that bypassed both, and
+        // precisely the one that fails when the vault is not up yet.
+        app.start_sync(true);
+        app
     }
 
     fn settings(&self) -> WindowSettings {
@@ -2904,6 +2984,17 @@ impl Application for ClearEmailApp {
             self.needs_rebuild = true;
         }
 
+        // A sync skipped for a missing password retries itself, so unlocking
+        // the vault brings mail in without the user having to ask again.
+        // No redraw is requested here: a failed retry changes nothing on
+        // screen (the toast is already up), and asking for one every few
+        // seconds would keep this demand-driven loop from ever idling. A
+        // successful one repaints when its results arrive.
+        if self.secret_retry_at.is_some_and(|t| std::time::Instant::now() >= t) {
+            self.secret_retry_at = None;
+            self.start_sync(true);
+        }
+
         // Only info toasts expire; an error stays until clicked or replaced.
         if let Some(StatusToast::Info { ref mut ttl, .. }) = self.status_message {
             *ttl -= dt;