Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions src-tauri/src/commands/calendar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<String>()
);
}
// 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);
Expand Down
14 changes: 14 additions & 0 deletions src-tauri/src/db/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -303,6 +304,19 @@ 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;")?;
Comment on lines +307 to +317
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This migration adds folders.parent_id, but initialize()'s CREATE TABLE folders still doesn't include parent_id. As a result, every fresh DB will always need the migration to reach the expected schema, and the schema definition stays out of sync with the runtime expectations. Update the CREATE TABLE IF NOT EXISTS folders statement to include parent_id TEXT so new installs start with the correct schema.

Copilot uses AI. Check for mistakes.
}

// 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");
Expand Down
77 changes: 77 additions & 0 deletions src-tauri/src/mail/caldav.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 version="1.0" encoding="utf-8"?>
<D:propertyupdate xmlns:D="DAV:">
<D:set>
<D:prop>
<D:displayname>{}</D:displayname>
</D:prop>
</D:set>
</D:propertyupdate>"#,
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::<String>()
)));
}

// 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::<String>()
)));
}

log::info!("caldav: PROPPATCH success");
Ok(())
}

// -----------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
_ => 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 {
Expand Down
8 changes: 8 additions & 0 deletions src-tauri/src/mail/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<GraphCalendarEvent>> {
Expand Down
47 changes: 47 additions & 0 deletions src-tauri/src/mail/jmap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,53 @@ 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);

// 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": [
"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(
Expand Down
Loading
Loading