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

commit88a016c174cbf1abf049cd3801b9b79d9a3f6b97
parent4f8d659e3b
authorLucas Galante <[email protected]>
date2026-07-27 12:24
feat: account passwords live in the Secret Service keyring

resolve_account_secrets() runs at load: a plaintext password in
accounts.json is migrated into the keyring (service=cce-email,
account=<email>) and the file rewritten with the field blanked; an empty
field resolves from the keyring. In-memory passwords stay resolved for
the IMAP/SMTP workers, and save_accounts redacts keyring-backed entries
(serde-skipped keyring_backed flag) so secrets never return to disk. The
mock account is exempt. With a locked keyring the provider (KeePassXC
here) pops its unlock dialog and load blocks until answered — the
standard desktop flow; if no keyring answers, plaintext keeps working.

Live-verified: migration blanked the on-disk field, secret-tool sees the
entry, and a restart synced Gmail with the keyring-resolved password.

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

 Cargo.toml  |  1 +
 src/main.rs | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 53 insertions(+), 1 deletion(-)

diff --git a/Cargo.toml b/Cargo.toml
index dd36dcc..9bdac45 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,6 +4,7 @@ version = "0.1.0"
 edition = "2021"
 
 [dependencies]
+keyring = { version = "3", features = ["sync-secret-service"] }
 cce-ui = { path = "../cce-ui" }
 smithay-client-toolkit = "0.19.2"
 calloop = "0.13.0"
diff --git a/src/main.rs b/src/main.rs
index e173739..ef290c0 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -39,6 +39,10 @@ struct AccountInfo {
     client_id: Option<String>,
     #[serde(default)]
     client_secret: Option<String>,
+    /// True when the password came from (or was migrated into) the Secret
+    /// Service keyring — save_accounts blanks it on disk. Never serialized.
+    #[serde(skip)]
+    keyring_backed: bool,
 }
 
 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -175,7 +179,12 @@ fn load_accounts() -> Vec<AccountInfo> {
     let path = get_accounts_path();
     if path.exists() {
         if let Ok(content) = std::fs::read_to_string(&path) {
-            if let Ok(accounts) = serde_json::from_str(&content) {
+            if let Ok(mut accounts) = serde_json::from_str::<Vec<AccountInfo>>(&content) {
+                if resolve_account_secrets(&mut accounts) {
+                    // A plaintext password just moved into the keyring —
+                    // rewrite the file now so it stops living on disk.
+                    save_accounts(&accounts);
+                }
                 return accounts;
             }
         }
@@ -193,11 +202,53 @@ fn load_accounts() -> Vec<AccountInfo> {
             token_expiry: None,
             client_id: None,
             client_secret: None,
+            keyring_backed: false,
         },
     ]
 }
 
+/// Resolve account passwords through the Secret Service (KeePassXC here).
+/// An empty on-disk password field is filled from the keyring; a plaintext
+/// one is migrated INTO the keyring (returns true so the caller rewrites the
+/// redacted file). In-memory passwords stay resolved for the IMAP/SMTP
+/// workers. The mock account never touches the keyring. If the keyring is
+/// locked, the provider pops its unlock dialog and this blocks until the
+/// user answers — the standard desktop flow.
+fn resolve_account_secrets(accounts: &mut [AccountInfo]) -> bool {
+    let mut migrated = false;
+    for acc in accounts.iter_mut() {
+        if is_mock_account(acc) {
+            continue;
+        }
+        let Ok(entry) = keyring::Entry::new("cce-email", &acc.email) else {
+            continue;
+        };
+        if acc.password.is_empty() {
+            if let Ok(p) = entry.get_password() {
+                acc.password = p;
+                acc.keyring_backed = true;
+            }
+        } else if entry.set_password(&acc.password).is_ok() {
+            acc.keyring_backed = true;
+            migrated = true;
+        }
+    }
+    migrated
+}
+
 fn save_accounts(accounts: &[AccountInfo]) {
+    // Keyring-backed passwords never go back to disk.
+    let redacted: Vec<AccountInfo> = accounts
+        .iter()
+        .map(|a| {
+            let mut a = a.clone();
+            if a.keyring_backed {
+                a.password = String::new();
+            }
+            a
+        })
+        .collect();
+    let accounts = &redacted;
     let path = get_accounts_path();
     if let Ok(content) = serde_json::to_string_pretty(accounts) {
         let _ = std::fs::write(&path, content);