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
31 changes: 31 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,37 @@
inside the view (they reach the callback as `#name`), macOS ⌘-key
routing for the view (untested).

## Mail drafts keep their `local:` id (#1884, #1881)

- `Store::reconcile_id` keeps a draft's `local:` id on its row as
`messages.local_id` (migrated in `Store::init` like the other columns, its
unique index created after the ALTER), so a caller still holding it (a
compose window left open across `SaveDraft`, a queued `SendDraft` retried
after a 503) keeps reaching the draft now stored under its Graph id. The
alias is on the row, so it goes with the message on every delete path
(`delete_message`, a delta `@removed`, a folder cascade); a delta upsert
keeps it.
- Every draft call resolves inside its one statement (atomic against the
sync thread's `reconcile_id` on its own connection): `draft` (the row's
`id` is the current id), `update_draft_fields`, `mark_sent`,
`move_message`, `delete_message` (`draft_row_sql!`), and the
`outbound_attachments` calls (`current_id_sql!`). Only a `local:` id is
looked up; `Store::current_id` is the same rule on its own.
- `ensure_draft_on_graph` decides create vs update by the row's current id,
so a second `SaveDraft`/`SendDraft` with a reconciled `local:` id is a
`PATCH`, never a second create. Their payloads and `Sent { id }` keep the id
the caller used. A local delete that also sends `SyncCommand::Delete` goes
through `Store::delete_message_resolved` (one `DELETE … RETURNING id`) and
sends the id it returns, else the id it had: the engine's `Delete` arm, and
lookxy's list deletes (`delete_and_send`) and `mail.delete`, as the list can
still hold a `local:` id the sync thread reconciled. `OutboxOp::Delete` of
an id that still resolves to `local:` is treated as never having reached
Graph and calls nothing; a delete that races the engine's create leaves the
Graph draft for a later sync. `Move`, `MarkRead`, `SetFlag` and
`SetCategories` with a `local:` id still reach Graph with it.
- `FakeServer::start_limited` gives a route a use limit; a used-up route is
skipped for the next match (a send that fails once, then succeeds).

## Format plugins

- A format plugin is a `yppx_*` shared library (`.dll`/`.so`/`.dylib`)
Expand Down
25 changes: 19 additions & 6 deletions lookxy/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -792,8 +792,7 @@ impl App {
ConfirmAction::Exit => self.quit = true,
ConfirmAction::DeleteThread(ids) => {
for id in ids {
let _ = self.store.delete_message(&id);
let _ = self.sync.cmd_tx.send(SyncCommand::Delete { id });
self.delete_and_send(id);
}
self.reload_messages();
}
Expand Down Expand Up @@ -2761,18 +2760,32 @@ impl App {
}
// A singleton / single message row: delete directly.
if let Some(id) = ids.into_iter().next() {
let _ = self.store.delete_message(&id);
self.delete_and_send(id);
self.reload_messages();
let _ = self.sync.cmd_tx.send(SyncCommand::Delete { id });
}
return;
}
let Some(id) = self.highlighted_message_id() else {
return;
};
let _ = self.store.delete_message(&id);
self.delete_and_send(id);
self.reload_messages();
let _ = self.sync.cmd_tx.send(SyncCommand::Delete { id });
}

/// Deletes message `id` locally and sends `SyncCommand::Delete` for it.
/// The list can still hold a draft's `local:` id after the sync thread
/// reconciled it to its Graph id (a `SaveDraft` drain reloads nothing),
/// so the command names the id of the row actually deleted
/// (`Store::delete_message_resolved`, #1884); with nothing deleted, the
/// id as it was.
fn delete_and_send(&mut self, id: String) {
let target = self
.store
.delete_message_resolved(&id)
.ok()
.flatten()
.unwrap_or(id);
let _ = self.sync.cmd_tx.send(SyncCommand::Delete { id: target });
}

/// Opens the move-folder popup over the highlighted message. A no-op if
Expand Down
25 changes: 23 additions & 2 deletions lookxy/src/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,9 +360,16 @@ fn delete(app: &mut App, args: &Json) -> Result<Json, String> {
.get_str("id")
.ok_or("mail.delete needs an 'id'")?
.to_string();
app.store.delete_message(&id).map_err(|e| e.to_string())?;
// A script may hold a draft's `local:` id after the draft was reconciled
// to its Graph id: the command names the id of the row deleted, as the
// local delete takes the alias with the row (#1884).
let target = app
.store
.delete_message_resolved(&id)
.map_err(|e| e.to_string())?
.unwrap_or_else(|| id.clone());
app.reload_messages();
let _ = app.sync.cmd_tx.send(SyncCommand::Delete { id: id.clone() });
let _ = app.sync.cmd_tx.send(SyncCommand::Delete { id: target });
Ok(Json::obj(vec![
("id", Json::Str(id)),
("deleted", Json::Bool(true)),
Expand Down Expand Up @@ -731,6 +738,20 @@ mod tests {
));
}

#[test]
fn delete_by_a_reconciled_local_id_sends_the_graph_id() {
let mut app = App::for_test_with_seeded_store();
let local = app.store.create_local_draft("Hi", "", "", "body").unwrap();
app.store.reconcile_id(&local, "G").unwrap();
let r = delete(&mut app, &args(vec![("id", Json::Str(local.clone()))])).unwrap();
assert_eq!(r.get("id").and_then(Json::as_str), Some(local.as_str()));
assert!(app.store.draft("G").unwrap().is_none());
assert!(matches!(
drain_last_command(&app),
Some(SyncCommand::Delete { id }) if id == "G"
));
}

#[test]
fn attachments_returns_stored_metadata() {
let app = App::for_test_with_seeded_store();
Expand Down
7 changes: 4 additions & 3 deletions lookxy/src/ui/compose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,10 @@ pub enum ComposeAction {
}

/// The compose view's state: the three header fields, the rich-text body
/// editor, which field has focus, and the id of the draft being edited (a
/// `local:<uuid>` id for a brand-new message, or the Graph id once a save
/// has reconciled it — opaque to this module either way).
/// editor, which field has focus, and the id the draft was opened under (a
/// `local:<uuid>` id for a brand-new message stays so after a save
/// reconciles the draft to its Graph id: the store still resolves it,
/// #1884) — opaque to this module either way.
pub struct Compose {
pub to: String,
pub cc: String,
Expand Down
89 changes: 89 additions & 0 deletions lookxy/src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -972,6 +972,95 @@ mod tests {
));
}

/// Files a local draft under the inbox in conversation `conv`, as the
/// list shows it, then reconciles it to `graph_id` behind the list's back
/// (the sync thread's `SaveDraft` drain reloads nothing). Returns the
/// `local:` id the list still holds.
fn stale_local_draft(app: &mut App, conv: &str, graph_id: &str) -> String {
let local = app
.store
.create_local_draft("Draft", "", "", "body")
.unwrap();
app.store
.upsert_message(
"inbox",
&Message {
id: local.clone(),
conversation_id: conv.into(),
subject: "Draft".into(),
from: Recipient {
name: String::new(),
address: String::new(),
},
to: vec![],
cc: vec![],
received: "2026-07-17T09:00:00Z".into(),
sent: String::new(),
is_read: true,
is_flagged: false,
has_attachments: false,
importance: "normal".into(),
preview: String::new(),
is_draft: true,
is_meeting_request: false,
categories: Vec::new(),
},
)
.unwrap();
app.reload_messages();
app.store.reconcile_id(&local, graph_id).unwrap();
local
}

fn sent_deletes(app: &App) -> Vec<String> {
let rx = app.test_cmd_rx.as_ref().unwrap();
std::iter::from_fn(|| rx.try_recv().ok())
.filter_map(|c| match c {
SyncCommand::Delete { id } => Some(id),
_ => None,
})
.collect()
}

#[test]
fn deleting_a_row_still_holding_a_reconciled_local_id_names_the_graph_id() {
// #1884: the list still holds `local:` after the draft became G.
let mut app = App::for_test_with_seeded_store();
app.threaded = false;
app.focus = Pane::List;
let local = stale_local_draft(&mut app, "", "G");
app.msg_index = app.messages.iter().position(|m| m.id == local).unwrap();

handle_key(&mut app, KeyEvent::from(KeyCode::Delete));

assert_eq!(sent_deletes(&app), vec!["G".to_string()]);
assert!(app.store.draft("G").unwrap().is_none());
assert!(app.messages.iter().all(|m| m.id != local));
}

#[test]
fn deleting_a_thread_holding_a_reconciled_local_id_names_the_graph_id() {
let mut app = App::for_test_with_seeded_store();
app.threaded = true;
app.focus = Pane::List;
// In m1's conversation, so the thread has two messages and asks first.
stale_local_draft(&mut app, "c1", "G");
app.row_index = 0;
assert!(matches!(
app.visible_rows.first(),
Some(crate::app::Row::Header(_))
));

handle_key(&mut app, KeyEvent::from(KeyCode::Delete));
assert!(app.confirm.is_some());
handle_key(&mut app, KeyEvent::from(KeyCode::Char('y')));

let mut deletes = sent_deletes(&app);
deletes.sort();
assert_eq!(deletes, vec!["G".to_string(), "m1".to_string()]);
assert!(app.store.draft("G").unwrap().is_none());
}

#[test]
fn delete_key_removes_the_row_and_clamps_selection() {
let mut app = App::for_test_with_seeded_store();
Expand Down
Loading
Loading