# Technical Documentation: Foundry376/Mailspring

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/Foundry376/Mailspring)

### Overview (README.md)

# 💌 Mailspring

**Mailspring is a new version of Nylas Mail maintained by one of the original authors. It's faster, leaner, and shipping today!** It replaces the JavaScript sync code in Nylas Mail with a new C++ sync engine based on [Mailcore2](https://github.com/MailCore/mailcore2). It uses roughly half the RAM and CPU of Nylas Mail and idles with almost zero "CPU Wakes", which translates to great battery life. It also has an entirely revamped composer and other great new features.

Mailspring's UI is open source (GPLv3) and written in TypeScript with [Electron](https://github.com/atom/electron) and [React](https://facebook.github.io/react/) - it's built on a plugin architecture and was designed to be easy to extend. Check out [CONTRIBUTING.md](https://github.com/Foundry376/Mailspring/blob/master/CONTRIBUTING.md) to get started!

Mailspring's sync engine is spawned by the Electron application and runs locally on your computer. [It is open source (GPLv3) and written in C++ and C.](https://github.com/Foundry376/Mailspring-Sync) For convenience, however, when you set up your development environment, Mailspring uses the latest version of the sync engine we've shipped for your platform so you don't need to pull sources or install its compile-time dependencies.



## Features

Mailspring comes packed with powerful features like Unified Inbox, Snooze, Send
Later, Mail Rules, Templates and more. Mailspring Pro, which you can unlock
with a monthly subscription, adds even more features for people who send a ton
of email: link tracking, read receipts, mailbox analytics, contact and company
profiles. **All of these features run in the client - Mailspring does not send
your email credentials to the cloud.** For a full list of features, check out
[getmailspring.com](https://getmailspring.com/).

## Download Mailspring

You can download compiled versions of Mailspring for Windows, Mac OS X, and
Linux (deb, rpm and snap) from
[https://getmailspring.com/download](https://getmailspring.com/download).

## Getting Help

You can find community-based help and discussion with other Mailspring users on our
[Discourse community](https://community.getmailspring.com/).

## Contributing

Mailspring is entirely open-source. Pull requests and contributions are
welcome! There are three ways to contribute: building a plugin, building a
theme, and submitting pull requests to the project itself. When you're getting
started, you may want to join our
[Discourse](https://community.getmailspring.com/) so you can ask questions and
learn from other people doing development.

[](CODE_OF_CONDUCT.md)

### Running Mailspring from Source

To install all dependencies and run Mailspring from its source code,
run the following commands from the root directory of the Mailspring repository:

```
export npm_config_arch=x64 # If you are on an M1 / Apple Silicon Mac
npm install
npm start
```

You can attach command line parameters by separating them using a double hyphen:

```
npm start -- --help
```

### Building Mailspring

To build Mailspring, you need to run the following command from the root directory
of the Mailspring repository:

```
npm run-script build
```

### Building A Plugin

Plugins lie at the heart of Mailspring and give it its powerful features.
Building your own plugins allows you to integrate the app with other tools,
experiment with new workflows, and more. Follow the [Getting Started
guide](https://Foundry376.github.io/Mailspring/) to write your first plugin in
five minutes.

- To create your own theme, check out the
  [Mailspring-Theme-Starter](https://github.com/Foundry376/Mailspring-Theme-Starter).

- To create your own plugin, check out the
  [Mailspring-Plugin-Starter](https://github.com/Foundry376/Mailspring-Plugin-Starter).

A plugin "store" like the Chrome Web Store is coming soon, and will make it
easy for other users to discover plugins you create. (Right now, users need to
"sideload" the plugins into the app by downloading them and copying them into
place.)

You can share and browse Mailspring Plugins, and discuss plugin development
with other developers, on our
[Discourse](https://community.getmailspring.com/).

### Building a Theme

The Mailspring user interface is styled using CSS, which means it's easy to
modify and extend. Mailspring comes stock with a few beautiful themes, and
there are many more which have been built by community developers. To start
creating a theme, [clone the theme starter](https://github.com/Foundry376/Mailspring-Theme-Starter)!

If you are updating an existing Nylas theme for Mailspring here is a
[step by step tutorial](https://community.getmailspring.com/t/updating-an-n1-nylas-mail-theme-for-mailspring/195).
Notice: as part of the update process you will probably need to [import mailspring base variables](https://github.com/Foundry376/Mailspring/issues/326#issuecomment-343757775).

You can share and browse Mailspring Themes, and discuss theme development with other developers, on our [Discourse](https://community.getmailspring.com/).

### Localizing / Translating

Mailspring (1.5.0 and above) supports localization. If you're a fluent speaker of
another language, we'd love your help improving translations. Check out the
[LOCALIZATION](https://github.com/Foundry376/Mailspring/blob/master/LOCALIZATION.md)
guide for more information. You can discuss localization and translation with
other developers on our [Discourse](https://community.getmailspring.com/).

### Contributing to Mailspring Core

Pull requests are always welcome - check out
[CONTRIBUTING](https://github.com/Foundry376/Mailspring/blob/master/CONTRIBUTING.md)
for more information about setting up the development environment, running
tests locally, and submitting pull requests.

---

### Community Plugins (`docs/community-plugins.md`)

# Community Plugins for Mailspring

This document lists known third-party plugins for Mailspring found on GitHub.

## Official Resources

| Repository | Description |
|------------|-------------|
| [Foundry376/Mailspring-Plugin-Starter](https://github.com/Foundry376/Mailspring-Plugin-Starter) | Official plugin template for creating your own plugins |

## Functional Plugins

### General Purpose

| Repository | Description | Stars |
|------------|-------------|-------|
| [Striffly/mailspring-avatars](https://github.com/Striffly/mailspring-avatars) | Adds avatars to contacts in Mailspring | ⭐76 |
| [bnesimsysadmin/AI-assistant-mailspring-plugin](https://github.com/bnesimsysadmin/AI-assistant-mailspring-plugin) | AI assistant for composing emails | - |
| [smartium/Mailspring-Plugin](https://github.com/smartium/Mailspring-Plugin) | Community plugin | - |

### Notifications & System Integration

| Repository | Description |
|------------|-------------|
| [ruslansin/launcher-api-support](https://github.com/ruslansin/launcher-api-support) | Adds Unity Launcher API dock notifications |
| [3nws/refresh-on-focus](https://github.com/3nws/refresh-on-focus) | Resync mail on window focus with 5 second cooldown |

### Productivity & Todo

| Repository | Description |
|------------|-------------|
| [jmanuel1/todoer](https://github.com/jmanuel1/todoer) | Automatically add starred emails to local todo.txt |

### Signatures

| Repository | Description |
|------------|-------------|
| [Raymo111/mailspring-plaintext-signatures](https://github.com/Raymo111/mailspring-plaintext-signatures) | Adds plaintext signature support |

### Calendar Integration

| Repository | Description |
|------------|-------------|
| [sham-sheer/edison-mail-calendar](https://github.com/sham-sheer/edison-mail-calendar) | Calendar with Google, Microsoft EWS, and CalDAV integration |
| [AbduT/ETCal-MailSpring](https://github.com/AbduT/ETCal-MailSpring) | Ethiopian Calendar plugin |

### Encryption / PGP

| Repository | Description |
|------------|-------------|
| [NgoHuy/mailspring-keybase](https://github.com/NgoHuy/mailspring-keybase) | Keybase PGP encryption plugin |
| [mirkoschubert/mailspring-pgp](https://github.com/mirkoschubert/mailspring-pgp) | Keybase PGP plugin |
| [dinoboy197/mailspring-openpgp](https://github.com/dinoboy197/mailspring-openpgp) | OpenPGP encryption plugin |

## Alternative Backends & Forks

These projects modify or replace Mailspring's backend services:

| Repository | Description | Stars |
|------------|-------------|-------|
| [1RandomDev/mailspring-api](https://github.com/1RandomDev/mailspring-api) | Self-hosted reimplementation of the Mailspring Sync backend | ⭐11 |
| [notpushkin/Mailspring-Libre](https://github.com/notpushkin/Mailspring-Libre) | Libre build with no telemetry (archived) | - |
| [algv/Mailspring-Libre](https://github.com/algv/Mailspring-Libre) | Fork of Mailspring-Libre | - |
| [arthurzenika/Mailspring-Libre](https://github.com/arthurzenika/Mailspring-Libre) | Fork of Mailspring-Libre | - |
| [exprez135/Mailspring-Libre](https://github.com/exprez135/Mailspring-Libre) | Fork of Mailspring-Libre | - |
| [jlgarridol/Mailspring-pro](https://github.com/jlgarridol/Mailspring-pro) | Mailspring-pro variant | - |

## Discovering More Plugins

- [GitHub Topic: mailspring-plugin](https://github.com/topics/mailspring-plugin) - All plugins tagged with this topic

## Creating Your Own Plugin

To create your own plugin, see:
- [Mailspring-Plugin-Starter](https://github.com/Foundry376/Mailspring-Plugin-Starter) - Official template
- [Creating Composer Plugins](./creating-composer-plugins.md) - Guide in this repo
- Built-in plugins in `app/internal_packages/` for reference (e.g., `composer-templates`, `phishing-detection`)

### Plugin Installation

Plugins should be copied or symlinked to:
- **macOS**: `~/Library/Application Support/Mailspring/packages/`
- **Linux**: `~/.config/Mailspring/packages/`
- **Windows**: `%APPDATA%/Mailspring/packages/`

You can find this directory by going to **Developer > Show Mailsync Logs** in Mailspring.

---

### Community Themes (`docs/community-themes.md`)

# Community Themes for Mailspring

This document lists known third-party themes for Mailspring found on GitHub.

## Official Resources

| Repository | Description |
|------------|-------------|
| [Foundry376/Mailspring-Theme-Starter](https://github.com/Foundry376/Mailspring-Theme-Starter) | Official theme template for creating your own themes |

## Popular Theme Collections

These themes are part of larger theme ecosystems:

| Repository | Description | Stars |
|------------|-------------|-------|
| [catppuccin/mailspring](https://github.com/catppuccin/mailspring) | Soothing pastel theme (Catppuccin) | ⭐58 |
| [dracula/mailspring](https://github.com/dracula/mailspring) | Official Dracula dark theme | ⭐17 |
| [faraadi/mailspring-nord-theme](https://github.com/faraadi/mailspring-nord-theme) | Nordic color scheme | ⭐72 |
| [getomni/mailspring](https://github.com/getomni/mailspring) | Omni theme | ⭐40 |
| [jakubzet/mailspring-matcha-theme](https://github.com/jakubzet/mailspring-matcha-theme) | Matcha theme | ⭐42 |

## Dark Themes

| Repository | Description | Stars |
|------------|-------------|-------|
| [jpminor/mailspring-isaac-dark-theme](https://github.com/jpminor/mailspring-isaac-dark-theme) | Newton-inspired dark theme | ⭐23 |
| [asparc/predawn](https://github.com/asparc/predawn) | Predawn (Atom/Sublime Text-inspired) | ⭐23 |
| [pierrenel/mailspring-dracula](https://github.com/pierrenel/mailspring-dracula) | Dracula variant | - |
| [GianniLab/Mailspring-Eleven-Dark](https://github.com/GianniLab/Mailspring-Eleven-Dark) | Windows 11-inspired dark | ⭐9 |
| [yz2/mailspring-minimal-dark-theme](https://github.com/yz2/mailspring-minimal-dark-theme) | Clean, minimal dark theme | ⭐8 |
| [chroxify/Sweet-Mailspring](https://github.com/chroxify/Sweet-Mailspring) | Custom dark theme | ⭐8 |
| [ZahidHasan/Dark-Light-Theme-Mailspring](https://github.com/ZahidHasan/Dark-Light-Theme-Mailspring) | Minimalist dark theme | - |
| [mjohnson8165/mailspring-arc-dark](https://github.com/mjohnson8165/mailspring-arc-dark) | Arc Dark theme | - |
| [dennisotugo/Mailspring-Adapta-Theme](https://github.com/dennisotugo/Mailspring-Adapta-Theme) | Adapta Dark theme | ⭐15 |
| [opes/mailspring-material](https://github.com/opes/mailspring-material) | Material Theme-inspired | - |
| [joelabair/Mailspring-OnLevel-Theme](https://github.com/joelabair/Mailspring-OnLevel-Theme) | N1 Level Up-inspired | - |
| [joeroe/mailspring-gruvbox-dark](https://github.com/joeroe/mailspring-gruvbox-dark) | Gruvbox color scheme | - |
| [kcaliban/Mailspring-Theme-Gruvbox](https://github.com/kcaliban/Mailspring-Theme-Gruvbox) | Gruvbox theme | - |
| [Infamoustrey/mailspring-theme-dracula](https://github.com/Infamoustrey/mailspring-theme-dracula) | Another Dracula variant | - |
| [luisfpg/Mailspring-Materia-Dark-Theme](https://github.com/luisfpg/Mailspring-Materia-Dark-Theme) | Materia Dark matching | - |
| [runeru/mailspring-runeru-theme](https://github.com/runeru/mailspring-runeru-theme) | GitHub colors-based dark | ⭐7 |
| [hakouguelfen/mailspring-theme](https://github.com/hakouguelfen/mailspring-theme) | Doom-One theme | - |

## Light Themes

| Repository | Description | Stars |
|------------|-------------|-------|
| [jpminor/mailspring-isaac-light-theme](https://github.com/jpminor/mailspring-isaac-light-theme) | Newton-inspired light theme | ⭐13 |
| [GianniLab/Mailspring-11-light-theme](https://github.com/GianniLab/Mailspring-11-light-theme) | Windows 11-inspired light | ⭐7 |
| [dincsi/adwaita-mailspring](https://github.com/dincsi/adwaita-mailspring) | GNOME Adwaita light theme | - |
| [NeoMahler/mailspring-idido](https://github.com/NeoMahler/mailspring-idido) | Polymail-inspired clean theme | ⭐10 |
| [danieljimeneznz/mailspring-agapanthus-theme](https://github.com/danieljimeneznz/mailspring-agapanthus-theme) | Inbox/Google Cloud-inspired | - |

## Other Themes

| Repository | Description | Stars |
|------------|-------------|-------|
| [antonioprates/mailspring-matcha-dark-azul](https://github.com/antonioprates/mailspring-matcha-dark-azul) | Manjaro matcha-dark-azul matching | ⭐7 |
| [webxperia/mailspring-theme-spark](https://github.com/webxperia/mailspring-theme-spark) | Spark theme | - |
| [m1guelpf/mailspring-theme](https://github.com/m1guelpf/mailspring-theme) | Personalized dark material | - |
| [nukeknurs/Reborn](https://github.com/nukeknurs/Reborn) | "Less is more" minimal theme | - |
| [tynguyen2k1/Mailspring-Nord-Theme](https://github.com/tynguyen2k1/Mailspring-Nord-Theme) | Tokyo Night-style theme | - |
| [Kasjonus/wiertara-theme](https://github.com/Kasjonus/wiertara-theme) | Wiertara theme | - |
| [Morgareth99/Mailspring-Nord](https://github.com/Morgareth99/Mailspring-Nord) | Nord theme variant | - |
| [ralphacpm/Mailspring-Nord-Theme](https://github.com/ralphacpm/Mailspring-Nord-Theme) | Nord theme variant | - |
| [avano/Mailspring-Theme](https://github.com/avano/Mailspring-Theme) | Custom theme | - |

## Discovering More Themes

- [GitHub Topic: mailspring-theme](https://github.com/topics/mailspring-theme) - All themes tagged with this topic
- [GitHub Topic: mailspring](https://github.com/topics/mailspring) - General Mailspring-related repos

## Creating Your Own Theme

To create your own theme:

1. Clone the [Mailspring-Theme-Starter](https://github.com/Foundry376/Mailspring-Theme-Starter)
2. Edit `package.json` with your theme's name and description
3. Modify the LESS files in `styles/`:
   - `index.less` - Main stylesheet
   - `ui-variables.less` - UI color variables
   - `theme-colors.less` - Theme color definitions

### Theme Installation

Install themes via **Edit > Install Theme...** (or **Mailspring > Install Theme...** on macOS) and select the theme folder.

Alternatively, copy or symlink themes to:
- **macOS**: `~/Library/Application Support/Mailspring/packages/`
- **Linux**: `~/.config/Mailspring/packages/`
- **Windows**: `%APPDATA%/Mailspring/packages/`

### Theme Structure

```
my-theme/
├── package.json          # Metadata (name, description, license)
├── styles/
│   ├── index.less        # Main stylesheet
│   ├── ui-variables.less # UI color variables
│   └── theme-colors.less # Theme color definitions
├── LICENSE.md
└── README.md
```

The `package.json` should include:
```json
{
  "name": "my-theme",
  "displayName": "My Theme",
  "theme": "ui",
  "version": "1.0.0",
  "engines": {
    "mailspring": "*"
  }
}
```

---

### Creating Composer Plugins (`docs/creating-composer-plugins.md`)

# Creating Composer Plugins

This guide covers how to build plugins that extend the Mailspring composer — the email drafting interface. Composer plugins can add toolbar buttons, modify draft content, inject UI components, extend the Slate rich-text editor, and hook into the send lifecycle.

## Table of Contents

- [Architecture Overview](#architecture-overview)
- [Plugin File Structure](#plugin-file-structure)
- [Plugin Lifecycle](#plugin-lifecycle)
- [Adding UI to the Composer](#adding-ui-to-the-composer)
  - [Component Roles](#component-roles)
  - [Props Available to Injected Components](#props-available-to-injected-components)
  - [Toolbar Buttons (Composer:ActionButton)](#toolbar-buttons-composeractionbutton)
  - [Footer Components (Composer:Footer)](#footer-components-composerfooter)
- [Modifying Draft Content](#modifying-draft-content)
  - [Using the DraftEditingSession](#using-the-drafteditigsession)
  - [Working with Plugin Metadata](#working-with-plugin-metadata)
- [ComposerExtension: Draft Lifecycle Hooks](#composerextension-draft-lifecycle-hooks)
  - [prepareNewDraft](#preparenewdraft)
  - [warningsForSending](#warningsforsending)
  - [applyTransformsForSending](#applytransformsforsending)
  - [sendActions](#sendactions)
- [Extending the Slate Rich-Text Editor](#extending-the-slate-rich-text-editor)
  - [Editor Plugin Architecture](#editor-plugin-architecture)
  - [The ComposerEditorPlugin Interface](#the-composereditorplugin-interface)
  - [Rendering Custom Nodes (Void Inlines)](#rendering-custom-nodes-void-inlines)
  - [HTML Serialization and Deserialization Rules](#html-serialization-and-deserialization-rules)
  - [Handling Keyboard Events](#handling-keyboard-events)
  - [Adding Editor Toolbar Buttons](#adding-editor-toolbar-buttons)
  - [Top-Level Components (Floating UI)](#top-level-components-floating-ui)
  - [Marks for Transient State](#marks-for-transient-state)
  - [Registering Your Editor Plugin](#registering-your-editor-plugin)
- [Complete Examples](#complete-examples)
  - [Example 1: Simple Composer Action Button (Translation)](#example-1-simple-composer-action-button-translation)
  - [Example 2: Full Composer Plugin with Editor Extension (Templates)](#example-2-full-composer-plugin-with-editor-extension-templates)
  - [Example 3: Inline Autocomplete with Floating UI (Emoji)](#example-3-inline-autocomplete-with-floating-ui-emoji)
- [Key Imports Reference](#key-imports-reference)
- [Design Patterns and Best Practices](#design-patterns-and-best-practices)

---

## Architecture Overview

The Mailspring composer is built on several layers:

```
┌──────────────────────────────────────────────────────────┐
│  Composer:ActionButton   Composer:Footer   ...           │  ← Injected via ComponentRegistry
├──────────────────────────────────────────────────────────┤
│  ComposerView  (app/internal_packages/composer/)         │  ← Renders InjectedComponentSets
├──────────────────────────────────────────────────────────┤
│  ComposerEditor (Slate v0 rich-text editor)              │  ← Slate plugins for editor behavior
├──────────────────────────────────────────────────────────┤
│  DraftEditingSession  /  DraftChangeSet                  │  ← Draft state management
├──────────────────────────────────────────────────────────┤
│  ComposerExtension (ExtensionRegistry.Composer)          │  ← Send lifecycle hooks
└──────────────────────────────────────────────────────────┘
```

There are three distinct plugin systems at play:

1. **ComponentRegistry** — Injects React components into named slots (roles) in the composer UI.
2. **ExtensionRegistry.Composer** — Registers `ComposerExtension` subclasses that hook into draft creation, validation, and sending.
3. **Slate Editor Plugins** — Extend the rich-text editor with custom nodes, marks, keyboard handling, toolbar buttons, and floating UI.

Most composer plugins use (1) and (2). Plugins that need to introduce new inline content types in the editor body (like template variables or emoji) also use (3).

---

## Plugin File Structure

```
app/internal_packages/my-composer-plugin/
├── package.json
├── lib/
│   ├── main.ts              # activate() / deactivate() lifecycle
│   ├── my-button.tsx         # React component for Composer:ActionButton
│   ├── my-extension.ts       # ComposerExtension subclass (optional)
│   └── my-editor-plugin.tsx  # Slate editor plugin (optional)
├── assets/
│   └── icon-my-plugin@2x.png
└── styles/
    └── my-plugin.less
```

### package.json

```json
{
  "name": "my-composer-plugin",
  "version": "0.1.0",
  "main": "./lib/main",
  "title": "My Plugin",
  "description": "Does something useful in the composer.",
  "icon": "./icon.png",
  "license": "GPL-3.0",
  "private": true,
  "isOptional": true,
  "engines": {
    "mailspring": "*"
  },
  "windowTypes": {
    "default": true,
    "composer": true,
    "thread-popout": true
  }
}
```

**`windowTypes`** controls which Electron windows load the plugin:
- `default` — Main application window
- `composer` — Composer popout window
- `thread-popout` — Thread popout window

Most composer plugins should set all three to `true`.

---

## Plugin Lifecycle

Every plugin must export `activate()` and `deactivate()` from `lib/main.ts`:

```typescript
import { ComponentRegistry, ExtensionRegistry } from 'mailspring-exports';
import MyButton from './my-button';
import MyComposerExtension from './my-extension';

export function activate() {
  ComponentRegistry.register(MyButton, { role: 'Composer:ActionButton' });
  ExtensionRegistry.Composer.register(MyComposerExtension);
}

export function deactivate() {
  ComponentRegistry.unregister(MyButton);
  ExtensionRegistry.Composer.unregister(MyComposerExtension);
}
```

`activate()` is called when the plugin is enabled (on startup for built-in plugins). `deactivate()` is called when disabled. Always unregister everything you registered.

---

## Adding UI to the Composer

### Component Roles

The composer renders `InjectedComponentSet` elements at specific named slots called **roles**. Your plugin registers React components into these roles via `ComponentRegistry.register()`.

| Role | Location | Description |
|------|----------|-------------|
| `Composer:ActionButton` | Toolbar above the editor | Buttons like Translate, Templates, Send Later |
| `Composer:Footer` | Below the editor body | Status bars, informational messages |
| `Composer:ActionBarWorkspace` | Below the action bar | Extended workspace area |
| `Composer:FromFieldComponents` | In the "From" row | Signature picker, account-related UI |

### Props Available to Injected Components

Components registered with a `Composer:*` role receive these props automatically:

```typescript
interface ComposerInjectedProps {
  draft: Message;                    // The current draft object
  session: DraftEditingSession;      // Session for applying changes
  headerMessageId: string;           // Unique ID for this draft
  threadId: string;                  // Thread ID (for replies)
  isValidDraft: () => boolean;       // Validates draft before sending
}
```

Source: `app/internal_packages/composer/lib/composer-view.tsx`

### Toolbar Buttons (Composer:ActionButton)

The most common extension point. Your component renders a button in the composer toolbar.

```
/* Detailed source-code truncated for AI context efficiency. */
```

Key conventions:
- Always set `static displayName` — required by `ComponentRegistry`.
- Use `tabIndex={-1}` on buttons to avoid stealing focus from the editor.
- Use `shouldComponentUpdate` to avoid re-rendering on every keystroke.
- Use `Actions.openPopover()` for dropdown menus, passing `direction: 'up'` since the toolbar is at the bottom of the composer.
- Use `RetinaImg` with `ContentIsMask` mode for icons that match the theme.

### Footer Components (Composer:Footer)

Footer components appear below the editor body. Used for status messages.

```typescript
// lib/my-status-bar.tsx
import React from 'react';
import { Message, MessageWithEditorState } from 'mailspring-exports';

export default class MyStatusBar extends React.Component<{
  draft: MessageWithEditorState;
}> {
  static displayName = 'MyStatusBar';

  static containerStyles = {
    textAlign: 'center' as const,
    width: 580,
    margin: 'auto',
  };

  render() {
    // Conditionally render based on draft state
    if (!this._shouldShow(this.props.draft)) {
      return <div />;
    }
    return (
      <div className="my-status-bar">
        Some informational message for the user.
      </div>
    );
  }
}
```

The templates plugin uses this to display "Press tab to move between blanks" when a template with variables is active. It reads the Slate editor state via `draft.bodyEditorState`:

```typescript
_usingTemplate(draft: MessageWithEditorState) {
  return (
    draft.bodyEditorState &&
    draft.bodyEditorState.document.getInlinesByType('templatevar').size > 0
  );
}
```

---

## Modifying Draft Content

### Using the DraftEditingSession

The `session` prop provides the primary API for modifying drafts:

```typescript
// Replace the entire body
session.changes.add({ body: '<div>New content</div>' });

// Change other draft fields
session.changes.add({ subject: 'New Subject' });
session.changes.add({ to: [new Contact({ email: 'a@b.com', name: 'Alice' })] });

// Commit changes immediately (otherwise debounced ~10s)
session.changes.commit();
```

`session.changes.add()` accepts a partial `Message` object. Changes are batched and debounced — multiple rapid calls are coalesced into a single database write.

**Body synchronization**: When you write to `body` (an HTML string), the system automatically converts it to a Slate `Value` for the editor via `convertFromHTML()`. If the editor is mounted, the conversion preserves undo history by using Slate's `replaceNodeByKey` and `insertFragment` operations.

Source: `app/src/flux/stores/draft-editing-session.ts`, `app/src/flux/stores/draft-change-set.ts`

### Working with Plugin Metadata

Drafts support arbitrary key-value metadata per plugin, persisted alongside the draft:

```typescript
// Read metadata
const metadata = draft.metadataForPluginId('my-plugin');

// Write metadata (on the session)
session.changes.addPluginMetadata('my-plugin', {
  enabled: true,
  setting: 'value',
});
```

This is useful for tracking plugin state (e.g., "link tracking is enabled for this draft").

---

## ComposerExtension: Draft Lifecycle Hooks

`ComposerExtension` provides static hooks into the draft lifecycle. Subclass it and register via `ExtensionRegistry.Composer.register()`.

Source: `app/src/extensions/composer-extension.ts`

### prepareNewDraft

Called once when a brand-new draft is created, before it's displayed:

```typescript
import { ComposerExtension, Message } from 'mailspring-exports';

export default class MyExtension extends ComposerExtension {
  static prepareNewDraft({ draft }: { draft: Message }) {
    // Modify the draft directly here - this is one of the few
    // places where direct mutation is safe.
    draft.body = draft.body + '<div>-- Sent from My Plugin</div>';

    // Set pristine = false if your changes make the draft
    // "valuable" (so it won't be auto-discarded)
    draft.pristine = false;
  }
}
```

### warningsForSending

Return warning phrases displayed in a confirmation dialog before sending:

```typescript
static warningsForSending({ draft }: { draft: Message }): string[] {
  const warnings = [];
  if (draft.body.includes('<code class="var">')) {
    warnings.push('with unfilled template variables');
  }
  if (!draft.subject) {
    warnings.push('without a subject');
  }
  return warnings;
  // Displayed as: "Send with unfilled template variables and without a subject?"
}
```

### applyTransformsForSending

Transform the draft body just before it's sent. Must be reversible.

```typescript
static applyTransformsForSending({
  draft,
  draftBodyRootNode,  // HTMLElement - only for HTML messages
  recipient,          // Contact - for per-recipient body variants
}: {
  draft: Message;
  draftBodyRootNode?: HTMLElement;
  recipient?: Contact;
}) {
  if (draft.plaintext) return;
  const metadata = draft.metadataForPluginId('my-tracking');
  if (!metadata) return;

  // Insert a tracking pixel
  const img = document.createElement('img');
  img.src = `https://track.example.com/open/${draft.headerMessageId}`;
  img.className = 'my-tracking-pixel';
  draftBodyRootNode.appendChild(img);
}
```

### sendActions

Register custom send actions that appear in the send button dropdown:

```typescript
static sendActions() {
  return [{
    title: 'Send Later',
    iconUrl: 'mailspring://send-later/assets/icon-send-later@2x.png',
    isAvailableForDraft: ({ draft }) => true,
    performSendAction: ({ draft }) => {
      // Custom send logic
    },
  }];
}
```

---

## Extending the Slate Rich-Text Editor

This is the most powerful and complex extension point. The composer uses **Slate v0** as its rich-text editor framework. Slate plugins can introduce custom inline node types, marks, keyboard shortcuts, toolbar buttons, and floating UI.

### Editor Plugin Architecture

The editor loads plugins from `app/src/components/composer-editor/conversion.tsx`:

```typescript
export const plugins: ComposerEditorPlugin[] = [
  ...InlineAttachmentPlugins,  // Inline images
  ...UneditablePlugins,        // Quoted text, tables, signatures
  ...BaseMarkPlugins,          // Bold, italic, underline, etc.
  ...TemplatePlugins,          // Template variables
  ...EmojiPlugins,             // Emoji insertion
  ...LinkPlugins,              // Hyperlinks
  ...BaseBlockPlugins,         // Blockquote, code blocks
  ...MarkdownPlugins,          // Markdown shortcuts
];
```

**Order matters**: Deserialization rules are applied in this order. More specific rules (template `<span data-tvar>`) must come before generic ones (plain `<div>`).

The schema defines which node types are void (non-editable):

```typescript
export const schema = {
  inlines: {
    templatevar: { isVoid: true },
    emoji:       { isVoid: true },
    image:       { isVoid: true },
    uneditable:  { isVoid: true },
  },
  blocks: {
    uneditable:  { isVoid: true },
  },
};
```

### The ComposerEditorPlugin Interface

Source: `app/src/components/composer-editor/types.ts`

```typescript
interface ComposerEditorPlugin {
  // --- Rendering ---
  renderNode?: (
    props: { node: Block | Inline; children: any; targetIsHTML: boolean;
             attributes?: any; isSelected?: boolean },
    editor: Editor | null,
    next: () => void
  ) => void | JSX.Element;

  renderMark?: (
    props: RenderMarkProps & { targetIsHTML?: boolean },
    editor?: Editor,
    next?: () => void
  ) => void | string | JSX.Element;

  // --- HTML Serialization ---
  rules?: Rule[];   // { deserialize(el, next), serialize(obj, children) }

  // --- Keyboard Events ---
  onKeyDown?: (event: React.KeyboardEvent, editor: Editor, next: () => void) => void;
  onKeyUp?: (event: React.KeyboardEvent, editor: Editor, next: () => void) => void;
  onChange?(editor: Editor, next: () => void);

  // --- UI Components ---
  toolbarComponents?: React.ComponentType<{
    editor: Editor; value: Value; className: string;
  }>[];
  toolbarSectionClass?: string;
  topLevelComponent?: React.ComponentType<{ editor: Editor; value: Value }>;

  // --- Application Commands ---
  appCommands?: { [command: string]: (event: CustomEvent, editor: Editor) => Editor };
}
```

Each handler receives a `next` function. Call `next()` to pass control to the next plugin in the chain. If your plugin handles the event, return without calling `next()`.

### Rendering Custom Nodes (Void Inlines)

To display custom inline elements in the editor, define a `renderNode` function. The `targetIsHTML` flag lets you render differently for the editor UI vs. the serialized HTML email:

```typescript
export const MY_TYPE = 'mycustomnode';

function renderNode({ node, attributes, children, isSelected, targetIsHTML }, editor, next) {
  if (node.type !== MY_TYPE) return next();

  const name = node.data.get('name');

  if (targetIsHTML) {
    // This JSX becomes the HTML that's saved and sent
    return <span data-my-type={name}>{name}</span>;
  }

  // This JSX is the interactive UI in the editor
  return (
    <span
      {...attributes}
      data-my-type={name}
      className={`my-node ${isSelected ? 'selected' : ''}`}
      contentEditable={false}
    >
      {name}
    </span>
  );
}
```

Template variables use this pattern — they render as `<span data-tvar="name">` in HTML but as styled, non-editable pill elements in the editor:

```typescript
// From template-plugins.tsx
return (
  <span
    {...attributes}
    data-tvar={name}
    className={`template-variable ${isSelected && 'selected'}`}
    contentEditable={false}
    title={name}
  >
    {name}
  </span>
);
```

### HTML Serialization and Deserialization Rules

Rules define how your custom nodes convert to/from HTML. This is critical because drafts are stored as HTML strings and loaded into the Slate editor on open.

```typescript
const rules: Rule[] = [
  {
    // HTML Element → Slate Node (when loading a draft)
    deserialize(el: Element, next) {
      // Check if this HTML element is one of ours
      if (el.dataset && el.dataset.myType) {
        return {
          object: 'inline',
          type: MY_TYPE,
          data: { name: el.dataset.myType },
        };
      }
      // Return nothing to let other plugins handle this element
    },

    // Slate Node → HTML (when saving/sending a draft)
    serialize(obj, children) {
      if (obj.object !== 'inline' || obj.type !== MY_TYPE) return;
      // Reuse renderNode with targetIsHTML: true
      return renderNode({ node: obj, children, targetIsHTML: true });
    },
  },
];
```

The template plugin also supports a legacy format by checking for `<code class="var">`:

```typescript
deserialize(el, next) {
  let name = el.dataset && el.dataset.tvar;
  if (el.tagName === 'CODE' && el.classList.contains('var')) {
    name = '';  // legacy format
  }
  if (name !== undefined) {
    return { object: 'inline', type: VARIABLE_TYPE, data: { name } };
  }
}
```

### Handling Keyboard Events

`onKeyDown` and `onKeyUp` let you intercept keyboard input in the editor.

**Template Tab Navigation** (from `template-plugins.tsx`):

```
/* Detailed source-code truncated for AI context efficiency. */
```

### Adding Editor Toolbar Buttons

Editor toolbar buttons appear in the formatting toolbar (bold, italic, etc.) — distinct from `Composer:ActionButton` which is in the composer action bar.

Use the `BuildToggleButton` factory for simple toggle buttons:

```typescript
import { BuildToggleButton } from './toolbar-component-factories';

const plugin: ComposerEditorPlugin = {
  toolbarSectionClass: 'my-section',
  toolbarComponents: [
    BuildToggleButton({
      type: MY_TYPE,
      button: {
        iconClass: 'fa fa-tag',
        isActive: (value: Value) => value.inlines.some(i => i.type === MY_TYPE),
        onToggle: (editor: Editor, active: boolean) => {
          if (active) {
            // Remove: convert inline back to text
            const node = editor.value.inlines.find(i => i.type === MY_TYPE);
            editor.removeNodeByKey(node.key).insertText(node.data.get('name'));
          } else {
            // Insert: create new inline from selection or default text
            const node = Inline.create({
              type: MY_TYPE,
              data: {
                name: editor.value.selection.isCollapsed
                  ? 'default'
                  : editor.value.fragment.text,
              },
            });
            editor.insertInlineAtRange(editor.value.selection, node).moveToEnd();
          }
        },
      },
    }),
  ],
};
```

For custom toolbar buttons, provide your own component:

```typescript
const MyToolbarButton = ({ value, editor, className }) => {
  const onClick = () => {
    // Modify the editor
    editor.insertInline({ object: 'inline', type: MY_TYPE, data: { name: 'test' } });
    editor.moveToStartOfNextText().focus();
  };

  return (
    <button className={className} onClick={onClick}>
      <i className="fa fa-star" />
    </button>
  );
};
```

Source: `app/src/components/composer-editor/toolbar-component-factories.tsx`

### Top-Level Components (Floating UI)

`topLevelComponent` renders a React component that floats above the editor. The emoji plugin uses this for the autocomplete dropdown:

```typescript
const plugin: ComposerEditorPlugin = {
  topLevelComponent: FloatingPicker,
  // ...
};

function FloatingPicker({ editor, value }: { editor: Editor; value: Value }) {
  if (!value.selection.isFocused) return null;

  // Read state from marks or document
  const myMark = value.activeMarks.find(m => m.type === 'my-typing-mark');
  if (!myMark) return null;

  // Position relative to the editor content area
  const sel = document.getSelection();
  const range = sel.getRangeAt(0);
  const target = range.endContainer.parentElement.closest('[data-my-typing]');
  if (!target) return null;

  const parent = target.closest('.RichEditor-content') as HTMLElement;
  const parentRect = parent.getBoundingClientRect();
  const targetRect = target.getBoundingClientRect();

  return (
    <div
      className="my-floating-picker"
      style={{
        position: 'absolute',
        left: targetRect.left - parentRect.left,
        top: targetRect.top + targetRect.height - parentRect.top,
      }}
    >
      {/* Picker content */}
    </div>
  );
}
```

### Marks for Transient State

Slate **Marks** are decorations on text (like bold or italic). The emoji plugin creatively uses marks to store transient autocomplete state:

```typescript
// When user types ":", add a mark to track the autocomplete session
editor.addMark({
  type: 'emojitype',
  data: { typed: '', suggestions: [], picked: '' },
});

// As user types more, update the mark data with suggestions
function updateMark(editor, existing, { typed, suggestions, picked }) {
  editor.moveAnchorBackward(typed.length);
  editor.removeMark(existing);
  editor.addMark({
    type: 'emojitype',
    data: { typed, suggestions, picked },
  });
  editor.moveToFocus();
}

// The floating picker reads this mark for its state
const emoji = value.activeMarks.find(i => i.type === 'emojitype');
const suggestions = emoji.data.get('suggestions');
const picked = emoji.data.get('picked');
```

This pattern keeps all autocomplete state inside the document, making it trivially accessible from both keyboard handlers and the floating UI component.

### Auto-Replace Patterns

The `slate-auto-replace` package converts text patterns into custom nodes. The template plugin uses this to convert `{{name}}` into a template variable inline:

```typescript
import AutoReplace from 'slate-auto-replace';

AutoReplace({
  trigger: '}',                       // Fires when user types }
  before: /({{)([^}]+)(})/,          // Pattern to match before cursor
  change: (editor: Editor, e, matches) => {
    const name = matches.before[2];   // Extract variable name
    const node = Inline.create({
      type: VARIABLE_TYPE,
      data: { name },
    });
    editor.insertInlineAtRange(editor.value.selection, node).moveToEnd();
  },
})
```

### Registering Your Editor Plugin

Editor plugins are loaded statically from `app/src/components/composer-editor/conversion.tsx`. To add yours, import it and add it to the `plugins` array:

```typescript
// In conversion.tsx
import MyPlugins from './my-plugins';

export const plugins: ComposerEditorPlugin[] = [
  ...InlineAttachmentPlugins,
  ...UneditablePlugins,
  ...BaseMarkPlugins,
  ...TemplatePlugins,
  ...EmojiPlugins,
  ...MyPlugins,         // Add your plugin here
  ...LinkPlugins,
  ...BaseBlockPlugins,
  ...MarkdownPlugins,
];
```

If your plugin introduces a void node type, add it to the schema:

```typescript
export const schema = {
  inlines: {
    // ...existing types...
    [MY_TYPE]: { isVoid: true },
  },
};
```

---

## Complete Examples

### Example 1: Simple Composer Action Button (Translation)

The translation plugin adds a toolbar button that opens a language picker popover and replaces the draft body with a translated version.

**Key files**: `app/internal_packages/translation/`

```typescript
// main.tsx — Lifecycle
export function activate() {
  ComponentRegistry.register(TranslateComposerButton, {
    role: 'Composer:ActionButton',
  });
}

export function deactivate() {
  ComponentRegistry.unregister(TranslateComposerButton);
}
```

```typescript
// composer-button.tsx — The button component
export class TranslateComposerButton extends React.Component<{
  draft: Message;
  session: DraftEditingSession;
}> {
  static displayName = 'TranslateComposerButton';

  _onTranslate = async (langName) => {
    Actions.closePopover();
    const translated = await translateMessageBody(this.props.draft.body, langCode);

    // Update the draft body through the session
    this.props.session.changes.add({ body: translated });
    this.props.session.changes.commit();
  };

  _onClick = () => {
    const buttonRect = (ReactDOM.findDOMNode(this) as HTMLElement).getBoundingClientRect();
    Actions.openPopover(
      <Menu
        items={Object.keys(languages)}
        itemContent={item => item}
        onSelect={this._onTranslate}
      />,
      { originRect: buttonRect, direction: 'up' }
    );
  };

  render() {
    if (this.props.draft.plaintext) return <span />;
    return (
      <button tabIndex={-1} className="btn btn-toolbar" onClick={this._onClick}>
        <RetinaImg url="mailspring://translation/assets/icon@2x.png"
                   mode={RetinaImg.Mode.ContentIsMask} />
      </button>
    );
  }
}
```

**Pattern**: ComponentRegistry action button + `session.changes.add({ body })` for content modification. No editor plugin needed.

### Example 2: Full Composer Plugin with Editor Extension (Templates)

The templates plugin is the most complete example. It uses all three plugin systems:

1. **ComponentRegistry** — `TemplatePicker` button (Composer:ActionButton) + `TemplateStatusBar` (Composer:Footer)
2. **ExtensionRegistry.Composer** — `TemplateComposerExtension` warns about empty template areas
3. **Slate Editor Plugin** — `template-plugins.tsx` adds the `templatevar` void inline type

**The Slate plugin provides**:
- A toolbar toggle button (tag icon) to create/remove template variables
- `renderNode` to display variables as styled pills
- `rules` for HTML round-tripping (`<span data-tvar="name">`)
- `onKeyDown` for Tab/Shift+Tab navigation between variables
- `AutoReplace` to convert `{{name}}` shorthand into variable nodes

**Template insertion flow**:
1. User clicks template picker button → `Actions.insertTemplateId()`
2. `TemplateStore` reads template HTML from disk and splits off the optional subject line, which is stored in a `<meta name="subject">` tag at the top of the file (see `template-file.ts`)
3. Template HTML is inserted via `session.changes.add({ body: templateHTML + existingContent, subject })` — the subject is only included when the template has one and the draft is not a reply or forward
4. The body setter converts HTML to Slate value, finding `<span data-tvar>` elements and creating `templatevar` inline nodes via the deserialization rules
5. `TemplateStatusBar` detects the variables and shows the "Press tab" hint

### Example 3: Inline Autocomplete with Floating UI (Emoji)

The emoji plugin demonstrates the most sophisticated editor extension pattern:

- **Void inline node** (`emoji`) for inserted emoji, rendered as platform-specific images
- **Mark** (`emojitype`) stores transient autocomplete state in the document
- **topLevelComponent** (`FloatingEmojiPicker`) reads the mark state and renders a positioned dropdown
- **onKeyDown** handles Enter/Space (confirm), Arrow keys (navigate suggestions)
- **onKeyUp** handles character input, triggers suggestion lookup, updates the mark

This "mark as state store" pattern is powerful for any inline autocomplete feature.

---

## Key Imports Reference

### From `mailspring-exports`

```typescript
import {
  // Registries
  ComponentRegistry,
  ExtensionRegistry,

  // Extensions
  ComposerExtension,

  // Flux
  Actions,
  DraftStore,
  DraftEditingSession,
  FeatureUsageStore,
  PreferencesUIStore,

  // Models
  Message,
  MessageWithEditorState,
  Contact,

  // Utilities
  localized,
  PropTypes,
} from 'mailspring-exports';
```

### From `mailspring-component-kit`

```typescript
import {
  RetinaImg,       // Retina-aware images
  Menu,            // Dropdown menu with search, headers, footers
  InjectedComponentSet,
} from 'mailspring-component-kit';
```

### From Slate (for editor plugins)

```typescript
import { Editor, Value, Inline, Block, Mark, Node } from 'slate';
import AutoReplace from 'slate-auto-replace';
import { ComposerEditorPlugin, Rule } from './types';
import { BuildToggleButton } from './toolbar-component-factories';
```

---

## Design Patterns and Best Practices

### Void Nodes for Non-Editable Inline Content

Template variables, emoji, and inline images are all **void inlines** — the cursor can move around them but can't edit their contents. Define them in the schema as `{ isVoid: true }` and set `contentEditable={false}` in the rendered element.

### Dual Rendering with targetIsHTML

Always handle both rendering contexts in `renderNode` and `renderMark`:
- `targetIsHTML: true` — Generating the HTML string that's saved/sent. Use simple, semantic HTML with `data-*` attributes so your deserialization rules can reconstruct the node.
- `targetIsHTML: false` — The interactive editor UI. Use rich React components with click handlers, styling, etc.

### next() Chain

Every `onKeyDown`, `onKeyUp`, `renderNode`, `renderMark`, and `onChange` handler receives a `next` function. Always call `next()` if your plugin doesn't handle the event, or other plugins won't run. If you do handle the event (e.g., `event.preventDefault()` on Tab), return without calling `next()`.

### Body vs. bodyEditorState

The draft has two representations of its body content:
- `draft.body` — HTML string (used by `session.changes.add()`, serialization, and `ComposerExtension` hooks)
- `draft.bodyEditorState` — Slate `Value` object (used by the editor and footer components that inspect editor state)

These are kept in sync via ES6 property descriptors. Writing to one lazily invalidates the other. Prefer using `session.changes.add({ body })` for programmatic changes.

### shouldComponentUpdate

The `draft` prop changes on **every keystroke**. Components registered with `Composer:ActionButton` should implement `shouldComponentUpdate` to avoid unnecessary re-renders unless they actually depend on draft content.

### Popover Direction

Since the composer toolbar is at the bottom of the composer, use `direction: 'up'` when opening popovers from toolbar buttons so the popover opens upward.

---

### Undo Redo Task Pattern (`docs/undo-redo-task-pattern.md`)

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

```typescript
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.

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

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

```typescript
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

```typescript
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

```typescript
event.ics = newIcs;  // Modified first!
const undoData = { ics: event.ics };  // Too late - this is the new state
```

✅ **Correct**: Capture before modification

```typescript
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

## Related Files

- `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`)

---

--- METRICS ---
- Files Extracted: 5
- Estimated Token Budget: ~13819 tokens
- Canonical Reference: https://codewiki.google/github.com/Foundry376/Mailspring
