# `rmf-tiny` — TinyMCE editor adapter for rmfmail

Status: **implemented and shipping.** Source: `y:/dev/utils/msgx/libs/rmf-tiny/src/adapter.ts`; built copy served as `client/lib/rmf-tiny.js` (via `bin/build-rmf-tiny.js`). Selectable in Settings → Compose → Editor. The design notes below are kept for history; see **Known TinyMCE quirks** at the bottom for the live gotcha list.

## Goal

Let users who want Thunderbird-class paste fidelity opt into TinyMCE without baking it into rmfmail itself. Bob 2026-05-09 verified: pasted a Word document into Thunderbird, tiptap, Quill, and TinyMCE — only TinyMCE preserved the formatting (boxed monospace block with borders, paragraph spacing, font preservation). Nothing else came close.

The license catch — TinyMCE is GPLv2 — means rmfmail (MIT) can't bundle it. The arms-length pattern is the resolution: rmfmail ships **no** TinyMCE bytes; a separate `rmf-tiny` package the user installs themselves provides the adapter + pulls in TinyMCE via npm.

## Shape

```
@bobfrankston/rmfmail            ← MIT, ships no TinyMCE
  client/compose/editor.ts       ← MailxEditor interface + factory
                                   gains a "tinymce" branch that
                                   dynamically imports rmf-tiny

@bobfrankston/rmf-tiny           ← MIT (the adapter glue is original code)
  package.json                   ← peerDependency: tinymce
  src/adapter.ts                 ← implements MailxEditor against TinyMCE
  README.md                      ← install instructions
```

User opts in:

```
npm install -g @bobfrankston/rmf-tiny tinymce
```

(Two packages explicit — keeps the licensing posture clean. rmf-tiny is just the adapter; tinymce comes from Tiny's own npm publication, not bundled with anything rmfmail-related.)

In rmfmail Settings → Compose → Editor: the dropdown grows a "TinyMCE (requires rmf-tiny)" option. If selected and the import fails, surface a status-bar error pointing at the install command.

## Why a separate package, not a flag inside rmfmail

- rmfmail's git history and npm tarball never contain TinyMCE → distribution layer is clean.
- rmf-tiny is small (~200 lines of adapter glue + a thin install README) and can be MIT — no GPL infection because it imports TinyMCE at runtime via the same kind of dynamic boundary npm packages always use. Combination at user's machine is fully GPL-permitted.
- Other editors can follow the same pattern (`rmf-ckeditor`, `rmf-prosemirror-pro`) without polluting rmfmail.

## Why call it `rmf-tiny`

Short, distinguishable from the upstream package, namespaces under your scope (`@bobfrankston/rmf-tiny`), and "tiny" is the upstream's own brand so the connection is obvious. Other naming options:
- `@bobfrankston/mailx-tinymce-adapter` (verbose, descriptive)
- `@bobfrankston/rmftiny` (no hyphen, matches rmfmail style)

`rmf-tiny` is fine; pick the form that scans cleanest in the install line.

## Files

### `rmf-tiny/package.json`

```json
{
  "name": "@bobfrankston/rmf-tiny",
  "version": "0.1.0",
  "description": "TinyMCE editor adapter for rmfmail. Bring-your-own TinyMCE.",
  "type": "module",
  "main": "src/adapter.js",
  "license": "MIT",
  "peerDependencies": {
    "tinymce": ">=6"
  },
  "peerDependenciesMeta": {
    "tinymce": { "optional": false }
  }
}
```

`peerDependencies` puts the install responsibility on the user — `npm install @bobfrankston/rmf-tiny` warns that `tinymce` must also be installed; rmf-tiny does not vendor or bundle TinyMCE.

### `rmf-tiny/src/adapter.ts`

```ts
// MIT-licensed adapter glue. Imports the user-installed TinyMCE at
// runtime; doesn't redistribute any TinyMCE bytes. Implements the same
// `MailxEditor` interface rmfmail's createEditor factory expects, so
// the host code path is identical.
import type { Editor as TinyEditor } from "tinymce";

export interface MailxEditor {
    setHtml(html: string): void;
    getHtml(): string;
    getText(): string;
    focus(): void;
    setCursor(pos: number): void;
    root: HTMLElement;
    // …other methods rmfmail's interface requires
}

export async function createTinyMceEditor(container: HTMLElement): Promise<MailxEditor> {
    // Dynamic import — fails clearly if the user hasn't installed tinymce.
    const tinymce = (await import("tinymce")).default;
    // Plus the plugins TinyMCE's paste-from-Word handler needs:
    await Promise.all([
        import("tinymce/themes/silver"),
        import("tinymce/icons/default"),
        import("tinymce/plugins/paste"),
        import("tinymce/plugins/lists"),
        import("tinymce/plugins/link"),
        import("tinymce/plugins/table"),
        import("tinymce/plugins/code"),
    ]);
    // ... initialize tinymce against `container`, return MailxEditor shim.
}
```

The actual init/wire-up is the work — wrapping TinyMCE's API in our `MailxEditor` shape, hooking paste events, setHtml/getHtml round-tripping, etc. Maybe ~200 lines.

### rmfmail-side change to `editor.ts`

```ts
export async function createEditor(
    container: HTMLElement,
    type: "quill" | "tiptap" | "tinymce"
): Promise<MailxEditor> {
    if (type === "tinymce") {
        try {
            const m = await import("@bobfrankston/rmf-tiny");
            return m.createTinyMceEditor(container);
        } catch (e: any) {
            const status = document.getElementById("status-sync");
            if (status) status.textContent = `TinyMCE editor not available — install: npm install -g @bobfrankston/rmf-tiny tinymce`;
            return createQuillEditor(container);  // fall back so compose still works
        }
    }
    if (type === "tiptap") return createTiptapEditor(container);
    return createQuillEditor(container);
}
```

Settings panel adds the "TinyMCE" option in the editor dropdown.

## Effort

- `rmf-tiny` package skeleton (package.json, README, basic adapter shell): ~30 minutes.
- Adapter wiring (init, setHtml/getHtml, paste handling, toolbar config): ~3 hours including testing the Word-paste case.
- rmfmail-side factory branch + Settings dropdown option: ~30 minutes.
- Documentation for users (README in rmf-tiny + Settings tooltip): ~30 minutes.

About half a day total.

## Order

1. Build `rmf-tiny` against your local TinyMCE install. Verify the Word-paste case actually works through your adapter.
2. Add the factory branch and Settings option to rmfmail.
3. Publish `rmf-tiny` to npm.
4. Document the opt-in install command in rmfmail's Settings tooltip and `README.md`.

Don't publish the adapter until the Word-paste case actually works end-to-end through the adapter — TinyMCE direct is one thing, our adapter possibly mangles it differently.

## Caveats

- The adapter must not re-implement parts of TinyMCE — it's a thin wrapper. Re-implementing parts of GPL code in MIT is the legal trap; staying purely in adapter / interface territory is safe.
- TinyMCE's bundle is large (~1MB minified). Users opting in pay that cost on the first compose-window load. Fine for opt-in; would be unacceptable as a default.
- `rmf-tiny` major version should track TinyMCE's major version (rmf-tiny@7.x for tinymce@7.x) so peerDependency mismatches are obvious.

## Known TinyMCE quirks

TinyMCE frequently gets caret / scroll / block behaviour wrong. Each entry
below is a quirk found in use and the workaround in `adapter.ts`. Add to
this list whenever a new one turns up — it's the running gotcha log.

- **Viewport doesn't follow `setCursor`.** After `setContent` of a long
  reply, collapsing the selection to the start (caret at top, where the
  user types above the quote) does NOT scroll the iframe — it can be left
  scrolled down inside the quoted block. Fix: `setCursor(0)` explicitly
  calls `editor.getWin().scrollTo(0, 0)`; non-zero positions call
  `editor.selection.scrollIntoView()`. (Bob 2026-05-18.)

- **Typing at a link's trailing edge extends the link.** With the caret
  collapsed at the end of an `<a>`, contenteditable keeps appending typed
  characters INTO the link — a whole sentence becomes link text. Fix: a
  `keypress` handler hops the caret to just after the `<a>` first.

- **Native spellcheck is force-enabled.** `adapter.ts`'s `init` handler
  sets `spellcheck="true"` on the body. mailx runs its own nspell checker
  and overrides this back to `"false"` in `wireSpellcheck` (with a
  MutationObserver) so the two don't double-underline.

- **`<p>` collapses to `<br>`.** Enter / paste sometimes produces a `<br>`
  line break where a `<p>` paragraph was expected, so paragraph spacing is
  lost until the lines are merged and Enter re-pressed. Not yet fixed —
  candidate: `forced_root_block` / paste-normalization config. (Bob
  2026-05-18, exact trigger still unclear.)
