Files
mailspring-mirror/docs/undo-redo-task-pattern.md
Ben GotowandClaude Opus 5.5 3ef4aebbbf Add an extensive fakeimap+dovecot test suite, then switch to 1:N message-to-folder relation (#2891)
* 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>
2026-09-26 15:55:40 -05:00

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:

  1. Have canBeUndone return true
  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, 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:

  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: 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

  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)