From 1a953b8121f952a00cbba6224ee1164fe9df4e25 Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Sun, 19 Apr 2026 13:27:13 +0200 Subject: [PATCH 1/4] fix(schema): migrate missing folders.parent_id column Code in db/folders.rs, jmap_sync.rs, and friends INSERTs into folders.parent_id but the column was never added to the CREATE TABLE or the migration list. Existing DBs had it via an orphaned ad-hoc migration from an earlier build; fresh installs blew up with "table folders has no column named parent_id" on first JMAP folder upsert. Add the ALTER migration so fresh installs match existing ones. --- src-tauri/src/db/schema.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src-tauri/src/db/schema.rs b/src-tauri/src/db/schema.rs index c389721..cb56973 100644 --- a/src-tauri/src/db/schema.rs +++ b/src-tauri/src/db/schema.rs @@ -303,6 +303,18 @@ fn run_migrations(conn: &Connection) -> Result<()> { )?; } + // Add parent_id column to folders. Existing DBs that were populated by + // older JMAP sync builds already had it; fresh installs didn't because + // the CREATE TABLE in initialize() was never updated to match. Without + // this column the first JMAP folder upsert fails with "no column named + // parent_id". + let has_folder_parent_id: bool = + conn.prepare("SELECT parent_id FROM folders LIMIT 0").is_ok(); + if !has_folder_parent_id { + log::info!("Migration: adding parent_id column to folders table"); + conn.execute_batch("ALTER TABLE folders ADD COLUMN parent_id TEXT;")?; + } + // Populate FTS index for existing messages (one-time migration) if !has_migration(conn, "fts5_initial_populate") { log::info!("Migration: populating FTS5 index for existing messages"); From 3fc51c34cbfd6ab65caf0c2662104a3ee7e41a89 Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Sun, 19 Apr 2026 13:27:35 +0200 Subject: [PATCH 2/4] feat(calendar): rename calendar from sidebar, pushing to remote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Rename… item to the calendar right-click context menu. The new update_calendar path loads the calendar + account, and when the name changes, pushes the new name to the remote server before writing the local DB: - CalDAV: PROPPATCH {DAV:}displayname on the calendar collection URL - JMAP: Calendar/set with an update entry - Graph: PATCH /me/calendars/{id} - Gmail: Google Calendar REST PATCH /calendars/{id}, falling back to CalDAV PROPPATCH if OAuth isn't configured Local-only calendars (no remote_id) still rename, just without a remote round-trip. On remote failure the DB stays unchanged so the user can retry. Also replace the fragile @click-on-sidebar-root close handler with a document-level click-outside listener that ignores right-button click events (WebKitGTK synthesises a click on right-mouse-release, which used to slam the menu shut the moment it opened). Closes #44. --- src-tauri/src/commands/calendar.rs | 97 +++++++++ src-tauri/src/mail/caldav.rs | 77 +++++++ src-tauri/src/mail/graph.rs | 8 + src-tauri/src/mail/jmap.rs | 43 ++++ src/components/calendar/CalendarSidebar.vue | 215 +++++++++++++++++++- 5 files changed, 438 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/calendar.rs b/src-tauri/src/commands/calendar.rs index 2aa5bda..9c32f32 100644 --- a/src-tauri/src/commands/calendar.rs +++ b/src-tauri/src/commands/calendar.rs @@ -83,11 +83,108 @@ pub async fn update_calendar( name, color ); + + // Load current calendar + account so we know whether the name changed + // and, if so, which protocol to push the rename through. Drop the + // reader before any await so the backend stays non-blocking. + let (existing, account) = { + let conn = state.db.reader(); + let cal = db::calendar::get_calendar(&conn, &calendar_id)?; + let acct = db::accounts::get_account_full(&conn, &cal.account_id)?; + (cal, acct) + }; + + let name_changed = existing.name != name; + let remote_id = existing.remote_id.clone().filter(|r| !r.is_empty()); + if name_changed { + if let Some(ref rid) = remote_id { + push_calendar_rename(&account, rid, &name).await?; + } else { + log::info!( + "update_calendar: skipping remote rename (no remote_id, local-only calendar)" + ); + } + } + let conn = state.db.writer().await; db::calendar::update_calendar(&conn, &calendar_id, &name, &color)?; Ok(()) } +/// Push a calendar rename to the account's remote server. Mirrors the +/// per-protocol dispatch in [`sync_calendars`]. Errors here must propagate +/// so the command leaves the local DB unchanged on remote failure. +async fn push_calendar_rename( + account: &db::accounts::AccountFull, + remote_id: &str, + new_name: &str, +) -> Result<()> { + if account.mail_protocol == "jmap" { + let jmap_config = crate::commands::sync_cmd::build_jmap_config(account).await?; + let conn_jmap = crate::mail::jmap::JmapConnection::connect(&jmap_config).await?; + conn_jmap + .rename_calendar(&jmap_config, remote_id, new_name) + .await?; + return Ok(()); + } + + if account.provider == "o365" { + let token = crate::mail::graph::get_graph_token(&account.id).await?; + let client = crate::mail::graph::GraphClient::new(&token); + client.rename_calendar(remote_id, new_name).await?; + return Ok(()); + } + + if account.provider == "gmail" { + // Prefer the Google Calendar REST endpoint; fall back to CalDAV + // PROPPATCH if REST fails (OAuth not configured, or remote_id is + // actually a CalDAV href). + if let Ok(token) = get_google_token(&account.id).await { + let url = format!( + "https://www.googleapis.com/calendar/v3/calendars/{}", + urlencoding::encode(remote_id) + ); + let http = reqwest::Client::new(); + let resp = http + .patch(&url) + .bearer_auth(&token) + .json(&serde_json::json!({ "summary": new_name })) + .send() + .await + .map_err(|e| { + crate::error::Error::Other(format!("Google Calendar PATCH failed: {}", e)) + })?; + if resp.status().is_success() { + return Ok(()); + } + let body = resp.text().await.unwrap_or_default(); + log::warn!( + "update_calendar: Google REST rename failed ({}), falling back to CalDAV", + body.chars().take(200).collect::() + ); + } + // Fall through to CalDAV below. + } + + if !account.caldav_url.is_empty() { + use crate::mail::caldav::{CalDavClient, CalDavConfig}; + let caldav_config = CalDavConfig { + caldav_url: account.caldav_url.clone(), + username: account.username.clone(), + password: account.password.clone(), + email: account.email.clone(), + }; + let client = CalDavClient::connect(&caldav_config).await?; + client.rename_calendar(remote_id, new_name).await?; + return Ok(()); + } + + Err(crate::error::Error::Other(format!( + "No remote rename path configured for account {} (provider={}, protocol={})", + account.id, account.provider, account.mail_protocol + ))) +} + #[tauri::command] pub async fn delete_calendar(state: State<'_, AppState>, calendar_id: String) -> Result<()> { log::info!("delete_calendar: id={}", calendar_id); diff --git a/src-tauri/src/mail/caldav.rs b/src-tauri/src/mail/caldav.rs index e8da180..a90720c 100644 --- a/src-tauri/src/mail/caldav.rs +++ b/src-tauri/src/mail/caldav.rs @@ -444,6 +444,66 @@ impl CalDavClient { Ok(()) } + /// PROPPATCH the `{DAV:}displayname` property to rename a calendar. + /// `calendar_href` is the calendar collection URL (as returned by + /// `list_calendars`). + pub async fn rename_calendar(&self, calendar_href: &str, new_name: &str) -> Result<()> { + let url = self.resolve_url(calendar_href)?; + log::info!("caldav: PROPPATCH rename calendar {} -> {}", url, new_name); + + let body = format!( + r#" + + + + {} + + +"#, + xml_escape(new_name) + ); + + let resp = self + .apply_auth( + self.http + .request(reqwest::Method::from_bytes(b"PROPPATCH").unwrap(), &url), + ) + .header("Content-Type", "application/xml; charset=utf-8") + .body(body) + .send() + .await + .map_err(|e| Error::Other(format!("CalDAV PROPPATCH failed: {}", e)))?; + + let status = resp.status(); + let text = resp + .text() + .await + .unwrap_or_else(|_| "(no body)".to_string()); + + // 207 Multi-Status is the canonical PROPPATCH success response; some + // servers reply with a plain 200. Any 2xx counts as OK provided the + // Multi-Status body doesn't flag the property as failed. + if !status.is_success() && status.as_u16() != 207 { + return Err(Error::Other(format!( + "CalDAV PROPPATCH returned {}: {}", + status, + text.chars().take(500).collect::() + ))); + } + + // Multi-Status body may report per-property failures even with a 207. + // Look for a non-2xx status inside the response XML. + if text.contains("HTTP/1.1 4") || text.contains("HTTP/1.1 5") { + return Err(Error::Other(format!( + "CalDAV PROPPATCH rejected displayname update: {}", + text.chars().take(500).collect::() + ))); + } + + log::info!("caldav: PROPPATCH success"); + Ok(()) + } + // ----------------------------------------------------------------------- // Internal helpers // ----------------------------------------------------------------------- @@ -773,6 +833,23 @@ pub fn generate_ical_event( lines.join("\r\n") } +/// Escape `&`, `<`, `>`, `"`, `'` for safe interpolation into XML text or +/// attribute values. Used by PROPPATCH to carry a user-supplied name. +fn xml_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + /// Convert ISO 8601 datetime to iCalendar datetime format. /// "2025-04-15T10:00:00Z" -> "20250415T100000Z" fn to_ical_datetime(iso: &str) -> String { diff --git a/src-tauri/src/mail/graph.rs b/src-tauri/src/mail/graph.rs index 624433a..b3e3ff1 100644 --- a/src-tauri/src/mail/graph.rs +++ b/src-tauri/src/mail/graph.rs @@ -555,6 +555,14 @@ impl GraphClient { .collect()) } + /// Rename a calendar via PATCH /me/calendars/{id}. + pub async fn rename_calendar(&self, calendar_id: &str, new_name: &str) -> Result<()> { + log::info!("Graph rename calendar: id={} -> {}", calendar_id, new_name); + let path = format!("/me/calendars/{}", urlencoding::encode(calendar_id)); + self.patch_json(&path, &serde_json::json!({ "name": new_name })) + .await + } + /// Fetch events in a time range via calendarView. /// Uses `Prefer: outlook.timezone="UTC"` so all times come back in UTC. pub async fn list_events(&self, start: &str, end: &str) -> Result> { diff --git a/src-tauri/src/mail/jmap.rs b/src-tauri/src/mail/jmap.rs index 40d5863..51bcc96 100644 --- a/src-tauri/src/mail/jmap.rs +++ b/src-tauri/src/mail/jmap.rs @@ -998,6 +998,49 @@ impl JmapConnection { Ok(calendars) } + /// Rename a JMAP calendar. Uses Calendar/set with an update entry. + pub async fn rename_calendar( + &self, + config: &JmapConfig, + calendar_id: &str, + new_name: &str, + ) -> Result<()> { + log::info!("JMAP rename calendar: id={} -> {}", calendar_id, new_name); + + let update = serde_json::json!({ + calendar_id: { "name": new_name } + }); + + let request = serde_json::json!({ + "using": [ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:calendars" + ], + "methodCalls": [ + ["Calendar/set", { + "accountId": self.account_id, + "update": update + }, "c1"] + ] + }); + + let resp = self.api_request(&request, config).await?; + + if let Some(err) = resp["methodResponses"][0][1]["notUpdated"][calendar_id].as_object() { + let desc = err + .get("description") + .and_then(|d| d.as_str()) + .unwrap_or("Unknown error"); + return Err(Error::Other(format!( + "JMAP Calendar/set rejected rename: {}", + desc + ))); + } + + log::info!("JMAP renamed calendar {}", calendar_id); + Ok(()) + } + /// Fetch calendar events, optionally filtered by calendar_id. /// Uses CalendarEvent/query + CalendarEvent/get with JSCalendar format. pub async fn fetch_calendar_events( diff --git a/src/components/calendar/CalendarSidebar.vue b/src/components/calendar/CalendarSidebar.vue index 8ee1702..bfb2239 100644 --- a/src/components/calendar/CalendarSidebar.vue +++ b/src/components/calendar/CalendarSidebar.vue @@ -1,5 +1,5 @@ @@ -581,4 +700,96 @@ async function unsubscribeThisCalendar() { .cal-context-menu .ctx-item:hover { background: var(--color-bg-hover); } + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; +} + +.rename-modal { + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 8px; + width: 360px; + max-width: calc(100vw - 32px); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); +} + +.rename-body { + padding: 18px 20px 4px; +} + +.rename-body h3 { + margin: 0 0 6px; + font-size: 15px; + color: var(--color-text); +} + +.rename-sub { + margin: 0 0 12px; + font-size: 12px; + color: var(--color-text-muted); +} + +.rename-input { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-bg-secondary); + color: var(--color-text); + font-size: 14px; +} + +.rename-input:focus { + outline: none; + border-color: var(--color-accent); +} + +.rename-error { + margin: 8px 0 0; + font-size: 12px; + color: #dc2626; +} + +.rename-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 20px 16px; +} + +.rename-btn-cancel, +.rename-btn-save { + padding: 6px 14px; + border-radius: 4px; + font-size: 13px; + cursor: pointer; +} + +.rename-btn-cancel { + background: var(--color-bg-tertiary); + color: var(--color-text); + border: 1px solid var(--color-border); +} + +.rename-btn-save { + background: var(--color-accent); + color: white; + border: 1px solid var(--color-accent); +} + +.rename-btn-save:disabled, +.rename-btn-cancel:disabled { + opacity: 0.5; + cursor: not-allowed; +} From 624f397467cffd5bd3423c97aba1272302bbf331 Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Sun, 19 Apr 2026 13:30:14 +0200 Subject: [PATCH 3/4] ci: apply cargo fmt to parent_id migration block CI uses a newer rustfmt that prefers method-chain formatting on the column probe. Match it. --- src-tauri/src/db/schema.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/db/schema.rs b/src-tauri/src/db/schema.rs index cb56973..cc24cc6 100644 --- a/src-tauri/src/db/schema.rs +++ b/src-tauri/src/db/schema.rs @@ -308,8 +308,9 @@ fn run_migrations(conn: &Connection) -> Result<()> { // the CREATE TABLE in initialize() was never updated to match. Without // this column the first JMAP folder upsert fails with "no column named // parent_id". - let has_folder_parent_id: bool = - conn.prepare("SELECT parent_id FROM folders LIMIT 0").is_ok(); + let has_folder_parent_id: bool = conn + .prepare("SELECT parent_id FROM folders LIMIT 0") + .is_ok(); if !has_folder_parent_id { log::info!("Migration: adding parent_id column to folders table"); conn.execute_batch("ALTER TABLE folders ADD COLUMN parent_id TEXT;")?; From 9c25f7822163b3eec5ba50fcb8dd074fcffc760f Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Sun, 19 Apr 2026 13:35:02 +0200 Subject: [PATCH 4/4] review: address Copilot findings on #110 1. JMAP Calendar/set used the literal key "calendar_id" instead of the runtime id. serde_json::json!({ identifier: ... }) stringifies the identifier name, not its value. Build the update map with serde_json::Map + insert(id.to_string(), ...), matching the pattern already used by update_calendar_event in the same file. Any apparent successful rename before this fix was either a server-side leniency or happened against a local-only calendar. 2. Add parent_id to the folders CREATE TABLE so fresh installs match the expected schema; the migration now becomes a no-op on new DBs while still repairing installs seeded by older builds. 3. Rename the global .modal-overlay class the rename modal was using to .cal-rename-overlay so it cannot clash with the several scoped .modal-overlay rules already in other components. --- src-tauri/src/db/schema.rs | 1 + src-tauri/src/mail/jmap.rs | 10 +++++++--- src/components/calendar/CalendarSidebar.vue | 4 ++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/db/schema.rs b/src-tauri/src/db/schema.rs index cc24cc6..d7d4d61 100644 --- a/src-tauri/src/db/schema.rs +++ b/src-tauri/src/db/schema.rs @@ -38,6 +38,7 @@ pub fn initialize(conn: &Connection) -> Result<()> { unread_count INTEGER DEFAULT 0, total_count INTEGER DEFAULT 0, uid_next INTEGER DEFAULT 0, + parent_id TEXT, UNIQUE(account_id, path) ); CREATE INDEX IF NOT EXISTS idx_folders_account ON folders(account_id); diff --git a/src-tauri/src/mail/jmap.rs b/src-tauri/src/mail/jmap.rs index 51bcc96..0e39d90 100644 --- a/src-tauri/src/mail/jmap.rs +++ b/src-tauri/src/mail/jmap.rs @@ -1007,9 +1007,13 @@ impl JmapConnection { ) -> Result<()> { log::info!("JMAP rename calendar: id={} -> {}", calendar_id, new_name); - let update = serde_json::json!({ - calendar_id: { "name": new_name } - }); + // Build the update map by hand: `serde_json::json!({ calendar_id: ... })` + // would emit the literal key "calendar_id", not the id's value. + let mut update = serde_json::Map::new(); + update.insert( + calendar_id.to_string(), + serde_json::json!({ "name": new_name }), + ); let request = serde_json::json!({ "using": [ diff --git a/src/components/calendar/CalendarSidebar.vue b/src/components/calendar/CalendarSidebar.vue index bfb2239..aef1e4d 100644 --- a/src/components/calendar/CalendarSidebar.vue +++ b/src/components/calendar/CalendarSidebar.vue @@ -380,7 +380,7 @@ async function unsubscribeThisCalendar() {