Files
mailspring-mirror/docs/undo-redo-task-pattern.md
manilabuiandClaude Opus 5.5 5e9cf917d0 Make calendar undo and redo save on Google (#2967)
* Add bumpEventSequenceUp, to re-send an older copy as a new revision

Google answers 409 to a VEVENT whose SEQUENCE is below the one it
holds. Undo and redo re-send a snapshot of the event, so each VEVENT
that differs from the copy last sent moves one past the higher of the
two and gets a fresh DTSTAMP. A VEVENT that matches apart from those
takes the last copy's, so guests see no revision of an occurrence
nobody changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Send a calendar undo or redo past the revision it replaces

Undo re-sent the pre-edit snapshot verbatim, SEQUENCE included, so
after a move Google held a higher SEQUENCE and answered 409. Redo had
the same problem after an undo, and so did undoing two edits to one
event in turn. Each window now records the ICS it last queued per
event, and undo and redo send their snapshot through
bumpEventSequenceUp against it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Match occurrences by instant when bumping an older copy's SEQUENCE

Google stores a RECURRENCE-ID sent in UTC back in the event's zone, so
the same occurrence reads as 20261022T103000Z in the copy we sent and
TZID=America/Chicago:20261022T053000 in the row it returns. Compared
as text, an unchanged override found no counterpart and was bumped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Send an undo or redo at the SEQUENCE last sent rather than past it

Google accepts a tie and rejects only a lower SEQUENCE. A bump lifted an
override past the master + 1 that createRecurrenceException restarts it
from, so the next edit of that occurrence came back 409, which a live
re-edit after undo and redo confirmed. bumpEventSequenceUp becomes
matchEventSequence.

Whether non-Google guests apply an update at a tied SEQUENCE is untested;
plans/calendar-open-gaps.md records it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Put back an override an undo leaves out, at its slot

Google keeps an override VEVENT that a PUT omits, so undoing the first
edit to one occurrence was accepted and changed nothing: a moved
occurrence stayed moved. The undo now sends that override back with the
master's content at its own slot, the occurrence as the series has it.
A slot the restored copy's EXDATEs cancel stays cancelled, so redoing
the cancellation of an overridden occurrence doesn't revive it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Record that an Outlook.com guest applies a tied update

Live: Google's update emails carried our SEQUENCE through with its own
fresh DTSTAMP, and an Outlook.com guest applied an undo and a re-edit
sent at the same SEQUENCE as the change before them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep only the untested part of the guest SEQUENCE note in the gaps doc

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Revert only the overrides the undone edit added, and only when the series is unchanged

Putting back every override the last copy had and the restored copy
lacked, matched by RECURRENCE-ID, misfired twice. A series move relabels
its overrides, so undoing one added an override at a slot the restored
series never generates. An occurrence response made after the undone
edit came back with the series' content and the old PARTSTAT.
revertAddedOverrides now runs on undo only, against the edit's own
before and after copies, and skips edits that changed the master. A
reverted all-day override takes the series' duration in days, so it
stays one day long on a DST change.

Adds specs that fail without redo's matchEventSequence call and without
undo recording what it queued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Revert an override when the restored series still has its slot

Requiring the undone edit to have added the override, with the master
unchanged, missed two cases. Undoing a series edit after undoing an
occurrence edit left that occurrence with the newer title, because the
override the first undo sent was never rebuilt. And adding the first
guest to one occurrence writes ORGANIZER onto the master, so nothing was
reverted. revertAddedOverrides now takes the copy last sent, runs on
undo and redo, and rebuilds every override that copy has and the
restored one lacks, as long as the restored series still generates that
slot. A slot a series move relabelled, or an EXDATE cancelled, is
skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:37:27 -05:00

7.5 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 tasks are queued via Actions.queueTask() or Actions.queueTasks(), UndoRedoStore._onQueue() registers one undo block for them if every task has canBeUndone set. Undo queues each task's createUndoTasks(), which defaults to [createUndoTask()]; redo queues createIdenticalTask(). Tasks marked engineWritesUndoData are reversed from the version the engine streams back (Pattern 3). Actions.queueUndoOnlyTask() registers a task for undo without sending it to the engine (Undo Send).

Task Requirements

For a task to be undoable, it must:

  1. Set canBeUndone to true. It is a field on Task, so assign it in the constructor; a subclass getter is a type error (TS2611).
  2. Implement createUndoTask() that returns a task to reverse the operation
  3. 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, snapshot the state on the task. See SyncbackEventTask (app/src/flux/tasks/syncback-event-task.ts):

  • forUpdating({ event, undoData, description }) takes undoData, the state from before the edit, and snapshots the state being written as newData.
  • The constructor sets this.canBeUndone = !!this.undoData, so a task built without undoData (creation, for example) is not undoable.
  • createUndoTask() restores undoData and swaps the two snapshots, so undoing the undo reapplies the edit.
  • createIdenticalTask() (redo) rebuilds the event from newData rather than reading this.event, which may have been mutated since the task was queued.
  • Both re-send their snapshot through matchEventSequence, measured against the ICS this window last queued for the event. A snapshot carries the SEQUENCE it had when captured, and Google answers 409 to one below the SEQUENCE it holds, so a snapshot cannot be sent verbatim. A tie is accepted, and changed VEVENTs go out at one. Both first run revertAddedOverrides, because Google keeps an override a PUT leaves out: an occurrence overridden since the snapshot goes back as the snapshot's series has it.

SyncbackMetadataTask follows the same shape with undoValue.

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:

  1. Set engineWritesUndoData = true on the task class. UndoRedoStore.undo() then resolves each such task through TaskQueue.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 through Actions.queueUndoOnlyTask, are reversed immediately from the client's copy.
  2. 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.
  3. 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: Set canBeUndone and implement createUndoTask

constructor(data: AttributeValues<typeof YourTask.attributes> = {}) {
  super(data);
  this.canBeUndone = !!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 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

  1. Perform the action
  2. Press Cmd/Ctrl+Z to undo
  3. Verify the model is restored to original state
  4. Press Cmd/Ctrl+Shift+Z to redo
  5. Verify the model has the modified state again
  • app/src/flux/stores/undo-redo-store.ts - UndoRedoStore implementation
  • app/src/flux/tasks/task.ts - Base Task class
  • app/src/flux/tasks/change-mail-task.ts - Base class for mail changes
  • app/src/flux/tasks/syncback-metadata-task.ts - Example of snapshot pattern
  • app/src/flux/tasks/syncback-event-task.ts - Calendar event undo implementation
  • app/src/flux/tasks/change-folder-task.ts - Engine-written snapshot (undoPlacements)