* Read message placements from the engine's `folders` map
A message can now have a live copy in several IMAP folders at once. The
engine reports them as `Message.folders` ({ folderId: flag bits }) and the
`folder` / `remoteFolder` / `clientFolder` keys are gone, so the client
resolves folders by id through CategoryStore (`folderIds()`,
`categories()`, `isInFolderWithRole()`, `isInSpamOrTrashOnly()`).
Read sites now reason over every copy: the message list hides a message
only when all of its copies are in spam or trash, the detailed header
shows one chip per folder (and drops the `role === 'al'` typo that never
matched All Mail), image autoloading treats any copy in spam as spam, and
MCP denies a message when any copy is in an excluded folder.
ChangeFolderTask carries `sourceFolderIds` so perspectives, snooze and
drag/drop move only the copies the user is looking at. Undo no longer
derives a single `previousFolder` on the client (and no longer refuses to
undo moves from mixed sources): the engine records `undoPlacements`
during its local phase and the client copies them to `restorePlacements`
on the undo task. UndoRedoStore builds the undo from the engine-updated
version of the task, waiting briefly for the local phase.
Delta ingestion inflates each model JSON in its own try/catch so one
unknown `__cls` or bad row cannot abort a whole batch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Wait for engine undo data only where it exists; approximate folder-move undo without it
UndoRedoStore waited on TaskQueue.waitForPerformLocal for every undo, but tasks
registered through Actions.queueUndoOnlyTask (Undo Send) never reach the engine,
so the wait always ran to its 2 s timeout and Undo Send landed too late to stop
a send in the last 2 s of its window. Tasks now opt in with
`engineWritesUndoData` (ChangeFolderTask); undo-only tasks and tasks the engine
does not annotate are reversed from the client's copy at once.
For ChangeFolderTask the ceiling is 10 s so a large selection's local phase can
finish, but the wait is abandoned after 2 s when the engine has not echoed the
task into TaskQueue at all (engine down). Without `undoPlacements`, the task now
reverses the move from the folders each thread was in when it was built (one
undo task per source folder via the new `Task.createUndoTasks()`), instead of
throwing into AppEnv.reportError for a multi-source move and silently dropping
the undo.
The constructor drops the destination from `sourceFolderIds`: a Gmail
perspective on the All Mail folder dragging onto a label produced a move to
All Mail scoped to All Mail, which `willBeQueued` rejected inside the drop
handler. `willBeQueued` keeps the check for tasks mutated after construction.
Also: report a delta inflation failure to Sentry once per model class instead of
only console.warn; "Move to…" scopes the move to the focused folder like remove,
swipe and drag; guard CategoryStore.byId against a missing accountId in
Message.categories() and ChangeFolderTask._folderById(); drop the unused
Message.isInFolderWithRole().
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Mark the message placements plan as implemented
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Undo a folder move with the engine's per-copy source folders
The engine now records undoPlacements as { messageId: [folderId, ...] },
the folder each moved copy was shown in, and no longer deletes a copy
whose destination already holds one. The undo task carries that map as
restorePlacements with sourceFolderIds = [this task's destination], so
the engine moves one destination copy back to each recorded folder.
`folder` on the undo only describes it; when none of the recorded
folders is known any more there is nothing to describe or restore, and
createUndoTasks returns no task.
The approximate undo (one task per folder each item was in when the
task was built) stays for moves the engine never reported on.
The plan and the undo pattern doc describe the new contract.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Skip the undo of a folder move that moved no copy
The engine always writes `undoPlacements` during a ChangeFolderTask's local phase and
leaves it `{}` when the move selected no copy. createUndoTasks treated that like a task the
engine never reported and fell back to the approximate undo, which sends whatever copies
are in the destination back to the source - including copies that were there before the
move. An empty map now produces no undo task; only an absent key falls back.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Describe orphans, the rebuilt snapshot and the narrowed lock in the placements plan
The engine's review pass replaced per-copy tombstones with MessageOrphan
records, rebuilds the folders snapshot on save, always moves a selected
copy, limits the syncedAt lock to recorded copies and detects Gmail by
capability. The plan now describes that design, marks the phases' status,
and lists the changes with their engine commits in a Revisions section.
The sweep's exact gating is left as TODO(sweep gating).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Describe the orphan sweep's per-folder gating in the placements plan
Replace the TODO(sweep gating) marker in section 2.5 with the rule as
implemented in mailsync dc73817: which folders count as covered by a pass,
the in-memory coveredAt per folder, the sweep bound
min over uncovered folders of max(coveredAt, passStart - 24h), and the
ORPHAN_SWEEP_MAX_WAIT override. Add the change to the section 10 revisions.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Record the review pass's changes and their rationale for message placements
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Add the post-reconcile fixes to the placements review doc
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Carry each moved copy's flag bits in ChangeFolderTask undo data
The engine (mailsync 050cb39) now records undoPlacements as
{ messageId: [{ folderId, bits }] } so an undo can send each copy back to the folder whose
unread/starred state it has; pairing copies in list order returned an unread Inbox copy
to Sent and a read one to Inbox, as observed on Office 365. The client passes the map
through unchanged, so only the type, _firstRestoreFolder and the specs change. Engine and
client ship together on this branch, so the old string-list shape is not accepted.
The placements plan and review record the new shape, and the review's accepted
trade-off is now marked fixed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Write up the sync engine follow-ups deferred from the placements review
Four tasks (provider quirk fixes, duplicate Sent copies, connection health,
cheaper deep scans) with the research behind each, plus a README with the
testing workflow and lessons from the placements work, for fresh agents to
pick up after message-placements merges.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Correct the connection-health task: the slow recovery was machine sleep
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Note the Yahoo PLT-Bulk test folder in the task README
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Record the live-testing fixes in the placements review doc
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Add the live Wi-Fi recovery measurement to the connection-health task
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Document the parallel harness run in the tasks README
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Describe removed-folder placement cleanup where it happens
The placements plan said Folder::afterRemove deletes a removed folder's
leftover MessageFolder rows. syncFoldersAndLabels now calls
deletePlacementsForFolder itself after store->remove, which also records
the orphans it leaves, and Folder::afterRemove only clears ThreadCounts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Add the O365 transient LIST refusal to the connection-health task
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Write up device-dependent thread ids as a follow-up task
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Scope archive and Not spam to the copies the user is acting on
The toolbar, hover and context-menu Archive actions now pass the focused
perspective to TaskFactory.tasksForArchiving, so only the copies shown in
that folder move. Archiving a self-sent message from Sent no longer moves
its Inbox copy instead. Not spam scopes to the account's Spam folder so a
thread's archived copies stay put. MCP, notifications and send-and-archive
have no view and keep the engine default.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Drop the undo entry for a move that selected no copy
When the engine reports empty undoPlacements, there is nothing to reverse,
so the block is removed instead of leaving a no-op undo behind.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Archive Gmail search results by removing the Inbox label
Search removal went through preferredRemovalDestination(), which returns
All Mail as a Folder on Gmail, so it queued a move to the folder the
message was already in. It now uses TaskFactory's archive and trash tasks,
which handle Gmail labels and folder providers alike.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Wait for a rule's tasks before running the next rule
The processor awaited an array instead of Promise.all, so a later rule could
read a thread before an earlier rule's changes applied. The wait is capped
so an engine that never acknowledges a task cannot stall rule processing.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Remove completed plan docs
* Bump mailsync
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
8.3 KiB
Undo/Redo Task Pattern in Mailspring
This document explains how to implement undoable tasks in Mailspring following the established patterns.
Overview
Mailspring uses a task-based architecture where operations are represented as Task objects that get queued and executed by the sync engine. The UndoRedoStore automatically tracks tasks that can be undone and provides undo/redo functionality.
How It Works
Automatic Registration
When a task is queued via Actions.queueTask(), the UndoRedoStore._onQueue() listener checks if the task has canBeUndone = true. If so, it automatically registers the task for undo:
// In UndoRedoStore
_onQueue = (taskOrTasks: Task | Task[]) => {
const tasks = taskOrTasks instanceof Array ? taskOrTasks : [taskOrTasks];
if (tasks.every(t => t.canBeUndone)) {
const block = {
tasks: tasks,
description: tasks.map(t => t.description()).join(', '),
undo: () => {
Actions.queueTasks(tasks.map(t => t.createUndoTask()));
},
redo: () => {
Actions.queueTasks(tasks.map(t => t.createIdenticalTask()));
},
};
this._onQueueBlock(block);
}
};
Task Requirements
For a task to be undoable, it must:
- Have
canBeUndonereturntrue - Implement
createUndoTask()that returns a task to reverse the operation - Implement
description()for the undo toast message
Patterns
Pattern 1: Simple State Toggle (ChangeStarredTask)
For operations that toggle a boolean state:
export class ChangeStarredTask extends ChangeMailTask {
starred: boolean;
// Inherited from ChangeMailTask: canBeUndone defaults to true
createUndoTask() {
const task = super.createUndoTask();
task.starred = !this.starred; // Simply flip the flag
return task;
}
}
Pattern 2: State Snapshot (SyncbackMetadataTask, SyncbackEventTask)
For operations where the "inverse" isn't a simple toggle, store the original state:
interface EventSnapshot {
ics: string;
recurrenceStart: number;
recurrenceEnd: number;
}
export class SyncbackEventTask extends Task {
event: Event;
undoData?: EventSnapshot; // Original state before modification
taskDescription?: string;
static forUpdating({ event, undoData, description }) {
return new SyncbackEventTask({
event,
calendarId: event.calendarId,
accountId: event.accountId,
undoData,
taskDescription: description,
});
}
get canBeUndone(): boolean {
return !!this.undoData; // Only undoable if we have original state
}
description(): string | null {
return this.taskDescription || null;
}
createUndoTask(): SyncbackEventTask {
// Restore the original state
const restoredEvent = this.event.clone();
restoredEvent.ics = this.undoData.ics;
restoredEvent.recurrenceStart = this.undoData.recurrenceStart;
restoredEvent.recurrenceEnd = this.undoData.recurrenceEnd;
// The undo task stores current state so redo works
const undoTaskUndoData: EventSnapshot = {
ics: this.event.ics,
recurrenceStart: this.event.recurrenceStart,
recurrenceEnd: this.event.recurrenceEnd,
};
return new SyncbackEventTask({
event: restoredEvent,
calendarId: this.calendarId,
accountId: this.accountId,
undoData: undoTaskUndoData,
taskDescription: localized('Undo %@', this.taskDescription),
});
}
}
Pattern 3: Engine-Written Snapshot (ChangeFolderTask)
Sometimes the client cannot know the original state when it queues the task. A folder move
is the canonical case: a message may have copies in several folders, and only the sync
engine knows which copies it moved and from where. The engine writes that snapshot onto the
task's data during its local phase (the same mechanism DestroyDraftTask uses to receive
stubIds), and the task streams back to the client as a Task persist delta.
export class ChangeFolderTask extends ChangeMailTask {
folder: Folder;
sourceFolderIds: string[];
undoPlacements?: SourceFoldersByMessageId; // written by the engine: where each moved copy came from
restorePlacements?: SourceFoldersByMessageId; // read by the engine on the undo task
engineWritesUndoData = true; // tells UndoRedoStore to wait for the engine's version
createUndoTasks() {
const task = super.createUndoTask(); // isUndo = true
task.folder = this._firstRestoreFolder(); // for the description only
task.sourceFolderIds = [this.folder.id]; // the copies to send back are in the destination
task.restorePlacements = this.undoPlacements;
return [task];
}
}
Three consequences for anyone using this pattern:
- Set
engineWritesUndoData = trueon the task class.UndoRedoStore.undo()then resolves each such task throughTaskQueue.waitForPerformLocal()before building the undo, so it runs against the engine-updated version. The wait is bounded (and abandoned early when the engine never echoes the task back), so an offline engine degrades to an approximate undo rather than none. Tasks without the flag, and tasks registered throughActions.queueUndoOnlyTask, are reversed immediately from the client's copy. - Implement
createUndoTasks()when the approximate undo can need several tasks (one per original folder), and return[]when nothing can be reversed;createUndoTask()stays for callers that can only queue one. createIdenticalTask()(used for redo) must strip the engine-written field so a re-run starts with a clean snapshot.
Implementation Steps
Step 1: Add Undo Data Attributes
Add attributes to store the original state:
static attributes = {
...Task.attributes,
undoData: Attributes.Obj({
modelKey: 'undoData',
}),
taskDescription: Attributes.String({
modelKey: 'taskDescription',
}),
};
Step 2: Capture State Before Modification
Before modifying the model, capture its current state:
function modifyEvent(event: Event, newData: EventData) {
// Capture BEFORE modifying
const undoData = {
ics: event.ics,
recurrenceStart: event.recurrenceStart,
recurrenceEnd: event.recurrenceEnd,
};
// Now modify
event.ics = newData.ics;
event.recurrenceStart = newData.start;
event.recurrenceEnd = newData.end;
// Queue with undo support
Actions.queueTask(SyncbackEventTask.forUpdating({
event,
undoData,
description: localized('Edit event'),
}));
}
Step 3: Implement canBeUndone and createUndoTask
get canBeUndone(): boolean {
return !!this.undoData;
}
createUndoTask(): YourTask {
if (!this.undoData) {
throw new Error('Cannot create undo task without undoData');
}
// Create task that restores original state
// Store current state in the undo task's undoData for redo
}
Anti-Patterns to Avoid
Don't Use Custom UndoBlock
❌ Wrong: Manually registering undo callbacks
// DON'T DO THIS
const undoBlock = {
description: 'My action',
undo: async () => { /* custom undo logic */ },
redo: async () => { /* custom redo logic */ },
};
UndoRedoStore.queueUndoBlock(undoBlock);
✅ Correct: Use task-based undo
// DO THIS
Actions.queueTask(MyTask.forUpdating({
model,
undoData: captureCurrentState(model),
description: 'My action',
}));
Don't Forget to Capture State First
❌ Wrong: Capturing after modification
event.ics = newIcs; // Modified first!
const undoData = { ics: event.ics }; // Too late - this is the new state
✅ Correct: Capture before modification
const undoData = { ics: event.ics }; // Capture original
event.ics = newIcs; // Now modify
Testing Undo
- Perform the action
- Press Cmd/Ctrl+Z to undo
- Verify the model is restored to original state
- Press Cmd/Ctrl+Shift+Z to redo
- Verify the model has the modified state again
Related Files
app/src/flux/stores/undo-redo-store.ts- UndoRedoStore implementationapp/src/flux/tasks/task.ts- Base Task classapp/src/flux/tasks/change-mail-task.ts- Base class for mail changesapp/src/flux/tasks/syncback-metadata-task.ts- Example of snapshot patternapp/src/flux/tasks/syncback-event-task.ts- Calendar event undo implementationapp/src/flux/tasks/change-folder-task.ts- Engine-written snapshot (undoPlacements)