EEmail Editor

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

The service (deployed to mail.lumitra.co with the merge) and, for the hidden preview markup, @marlinjai/email-editor-core (editor set, in the next editor release; the service builds it from the workspace, so production needs no release).

Changed

  • Letters (mailings with no topic, one recipient) now carry List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC 8058) like broadcasts and automation emails, pointing at the same page as their {{unsubscribe_url}}; a mail client's one-click blocks the address on every topic, as the page's primary action does. Test sends of a letter carry them too, pointing at the preview link. Notifications and the signup double opt-in mail stay without them, on purpose. ŌPUNTIA's visitor confirmations, whose only way out was the footer link, had been junked by iCloud. Plan: docs/plans/2026-09-26-list-unsubscribe-on-letters.md.
  • Every template send (broadcasts, automation emails, letters, notifications, test sends) and the signup double opt-in mail from a custom template now carry a text/plain part next to the HTML (multipart/alternative), made from the final HTML after merge fields, insertions and click tracking: links as text (url), the unsubscribe link kept, hidden elements and images dropped, entities decoded, non-ASCII text such as ŌPUNTIA intact. A mail-tester.com run on an ŌPUNTIA letter had scored MIME_HTML_ONLY.
  • The preheader (hidden preview line) is one display:none;max-height:0;overflow:hidden;mso-hide:all element that replaces the template's compiled preview instead of adding a second one. The old font-size:1px;...;opacity:0 markup matched SpamAssassin's invisible-text subrule, which with any ordinary Message-ID scores FONT_INVIS_MSGID (2.5 points); a local SpamAssassin 4.0.1 run on the ŌPUNTIA base template went from 3.3 to 0.0 points. Stored snapshots compiled before are restyled at send time.
  • Core: MJMLCompiler.compile and compileInBrowser rewrite MJML's mj-preview div to the same safe markup (normalizeHiddenPreview), and replaceHiddenPreview, escapePreviewText, hiddenPreviewElement, HIDDEN_PREVIEW_STYLE and MJML_PREVIEW_STYLE are exported from the default entry.
  • The SMTP transport sends an explicit Message-ID on the From address's domain (<8-4-4-4-12 hex@domain>), the same on every attempt of the same message, and greets the server with the host name of PUBLIC_BASE_URL instead of nodemailer's [127.0.0.1] (which iCloud recorded in its Received header). Resend keeps stamping its own Message-ID. Plan: docs/plans/2026-09-26-deliverability-message-id-preheader-text.md.

[0.10.0] - 2026-09-28 (mail set)

The mail set (mail-v0.10.0: mail-contract, mail-sdk, mail-react) and the service (deployed to mail.lumitra.co with the merge; migration 0030 runs at start) and the dashboard. Asked for by ŌPUNTIA (Sharon, 2026-09-28): a hand-written letter in its reader's language. Plan: docs/plans/2026-09-28-mailing-from-one-language-version.md.

Added

  • mailings.create takes template_locale beside template_id: the mailing is written from that one language version of the template, the main one or a translation, draft or ready. It takes the version's document, subject, preheader and insertions, its locale and send_locale are set to the version, and it sends that version alone: none of the template's other versions is compiled at the start, listed in mailings.languages or sent by mailings.test. A version without a subject of its own takes the one given, so nothing on this path needs a per-language subject. Refusals, all validation_failed: template_locale_missing (the template's languages in details.languages), template_locale_incomplete (a draft missing a text for one of its insertions, in details.missing), send_locale_from_template_locale (a different send_locale, on create or update). Mailing.template_locale returns the choice, null for every other mailing; a duplicate keeps it. ŌPUNTIA's Contact reply, written in German to a German reader, went out as the German frame without the reply as soon as its German version was ready.
  • The dashboard's New-mailing form offers "Write it from": each recipient's own language (as before) or one version of the chosen template. A mailing written from one version shows its language as fixed, and "Use its current version" takes that version again (refused for a template without it).

[0.9.0] - 2026-09-28 (mail set)

The mail set only (mail-v0.9.0: mail-contract, mail-sdk, mail-react), with the service's templates.delete base_version and sorted templates.list (deployed with their merges) and the dashboard's templates list. Published by the merge that raises the set's version (#130).

Added

  • TemplateList can retire templates, opt in per host: canArchive offers Archive on live templates and Unarchive on archived ones (a template update with its base_version; a conflict is shown and the list reloaded), and canDelete offers Delete on archived templates only, confirmed by typing the template's name, with the dashboard's wording (the template and its history are deleted, mailings made from it keep their own copy). Delete is a step stricter than the dashboard's, which offers it on every row, so a live template is never one click from gone in a host's Studio. locked lets the host disable both for templates it must keep (one its code sends: the service deletes a template whatever still points at it), with its reason on the button. Each action has its own operations for the bridge's allow, TEMPLATE_LIST_ARCHIVE_OPERATIONS (templates.update) and TEMPLATE_LIST_DELETE_OPERATIONS (templates.delete), kept out of TEMPLATE_LIST_OPERATIONS so no host that only lists templates starts forwarding writes, and no host that only archives forwards a delete. The delete carries the version the person confirmed, so a template somebody unarchived or renamed while the dialog was open is not deleted. ŌPUNTIA's Studio could list archived templates but never remove one.
  • createMailBridge takes refuse, the host's rule on a single call: it sees the validated call (operation, path parameters, query, body) and returns a reason to refuse it (forbidden, 403, the reason as the message, reason: refused_by_host) or nothing to forward it. It is what enforces TemplateList's locked for a caller that skips the view, which the buttons alone never could.
  • DELETE /v1/templates/:id (templates.delete) takes an optional base_version query: the template is deleted only while it is still at that version, checked in the delete statement itself, and otherwise the answer is conflict (409) with current_version and nothing is deleted. Without it a delete works as before, so the dashboard is unaffected. The SDK's templates.delete(id, opts?) takes it beside the request options ({ base_version, onBehalfOf, signal }), so an existing caller's delete(id, { onBehalfOf, signal }) is unchanged.
  • templates.list sorts: sort is updated_at (the default), name (without regard to case) or version, and order is asc or desc, each sort having its natural default (the name A to Z, the most recent change and the highest version first). Ties fall back to the id in the same direction, so the keyset cursor pages through any order without gaps or repeats. The order is the service's, so a list sorts every template and not only the page it has loaded.
  • TemplateList and the dashboard's templates list sort by name, version and last change from their column headers (the dashboard keeps the order in the address), and their columns can be resized by dragging a header's edge or with the arrow keys on it (a double-click resets a column). The widths are kept per viewer in the browser (storageKey in TemplateList, null for none) and never reach the service; useResizableColumns is exported from @marlinjai/mail-react/views for any other table.
  • Renaming a template, name and description, from the list: canRename in TemplateList (opt in, under its own TEMPLATE_LIST_RENAME_OPERATIONS (templates.update); not held back by locked, since a name changes nothing that is sent) and Rename on every row of the dashboard's list for a role that can write. Only what changed is sent, on the row's base_version; an emptied description is cleared; a conflict keeps the dialog and what was typed, reloads the list and lets the person save again on the current version. The service counts a rename as a new version, as it does every save. The dashboard could rename only from the editor, and could not edit a description anywhere.

[0.6.1] - 2026-09-26 (editor set)

The editor set only (editor-v0.6.1: core, blocks, ui, editor); the mail set moves to 0.8.1 below. Defects a production re-check of the ŌPUNTIA Studio found after 0.6.0.

Fixed

  • A block's name tag, moved above the node in 0.6.0 so it stopped covering chips, covered the bottom of the text above it instead (the "STAY CLOSE" eyebrow over the greeting). The canvas now measures each node's content inside its padding (Box.contentTop and Box.contentBottom), and the tag, a fixed 16 pixels, sits in the free band between the content above and the node's own first content. With no such band it stays above the node, half see-through and letting clicks through (data-fit="tight"). The node's actions follow the tag's side.

[0.8.1] - 2026-09-26 (mail set)

The mail set only (mail-v0.8.1: mail-contract, mail-sdk, mail-react).

Fixed

  • The editor's Insertions tab scrolled sideways: a value with no break in it (the declaring app's commit, a URL example) ran past the sidebar's edge. Values now wrap inside the insertions surface, and a commit is shown as its 7 characters, the whole hash in the title.
  • TemplateEditor's language tab row was 1 pixel taller than its box, so a scrollbar showed at its right end in every template. The chosen tab's underline now sits inside the row, which scrolls only sideways.

[0.6.0] - 2026-09-26 (editor set)

The editor set only (editor-v0.6.0: core, blocks, ui, editor); the mail set moves to 0.8.0 below. Slice X8 of docs/plans/2026-09-25-insertions-ux.md: end to end, accessibility, and the defects a production smoke test of the ŌPUNTIA Studio found.

Fixed

  • A save could store nothing while showing "Saved". useEditorDocument's settled() waited out the editor's report delay counted from input on the host page, but canvas edits happen inside the sandboxed frame, whose events never reach the host, so a save made about a second after typing read the old document. The store now announces every change at once through a new onEdit prop, useEditorDocument.edited() feeds settled() (capped at 3 seconds), and every host in this repo wires it. A host that renders its own editor component must forward onEdit.
  • The canvas fits a narrow host (the email scales instead of scrolling sideways), node tags sit above a node instead of over its first chip, and the five sidebar tabs share one row.
  • Accessibility: axe clean on the editor with insertions (Layers, the formatting toolbar, the Insert menus with distinct names, the tabs); the Insert menu stays inside whatever clips it; a document titled "Untitled Template" opens with the host's template name.

[0.8.0] - 2026-09-26 (mail set)

The mail set only (mail-v0.8.0: mail-contract, mail-sdk, mail-react).

Fixed

  • mail-react's TemplateEditor and the insertions surface: the examples popover fits and scrolls within the editor, "Empty" toggles and "Reset examples" exists, an empty value's chip says what the service does with the line, the language switch keeps the open sidebar tab, accent and danger text keep their contrast, and a refused "Save mine over it" names the insertions this editor does not have and offers "Load their version" (unknownRefusedInsertions, now in mail-contract for both editors).
  • TemplateEditor wires the editor's onEdit, so a save never drops an edit typed on the canvas.

[0.5.0] - 2026-09-26 (editor set)

The editor set only (editor-v0.5.0: core, blocks, ui, editor); the mail set moves to 0.7.0 below. Slices X2 and the editor half of X4 of docs/plans/2026-09-25-insertions-ux.md: the seam a host uses to place and manage its own tokens (merge fields, insertions) on the canvas, which the mail set's insertions surface is built on.

Added

  • The token seam: EmailEditor's new tokens prop (EditorTokens: list, render, renderTokenInspector, create) decorates every {{name}} the host owns as an atomic, focusable chip on the canvas after each compile, showing the host's text for it while keeping the exact raw token underneath (a lossless unwrap: in-place editing, copy and cut all see or carry the raw token, never the rendered text, so the compile stays byte-equal to a plain document). A click selects the token (EditorUIStore.selectToken, mutually exclusive with a node selection and outside undo like it); the Inspector then renders the host's renderTokenInspector, and the text toolbar gains an Insert menu (TokenMenu) that places a chip at the caret.
  • Two more optional props: extraTabs adds the host's own tabs to the left sidebar (a count, an attention dot, a real tablist with arrow keys), and toolbarItems puts the host's own controls beside the device toggle. The editor exports InspectorSection, InspectorPanel and TokenMenu for a host's own Inspector content.
  • An editor handle: onReady(handle) on EmailEditorReact (and useEditorHandle() inside it, for a sidebar tab, the Inspector slot or a toolbar item) gives a host selectToken, scrollToToken and renameToken from outside the editor's own tree, without a workaround mounted through toolbarItems. renameToken changes every {{from}} to {{to}} in the document as one step in undo.
  • Node labels on the canvas and in Layers show a token by its host-given label, not its raw name; the left sidebar's tabs wrap instead of truncating once a host adds its own.

[0.7.0] - 2026-09-26 (mail set)

The mail set only (mail-v0.7.0: contract, SDK, mail-react); the editor set moves to 0.5.0 above, which this release is built on. Slices X1 and X3 through X4 of docs/plans/2026-09-25-insertions-ux.md: what a template receives, and the insertions surface (chips, the Insertions tab, the Inspector, the example values) shared between mail-react's TemplateEditor and the dashboard, replacing the panel each had of its own. The service and the dashboard (an app in this repository, not a published package) deployed with each merge; migration 0029 ran on the service's start.

Added

  • Template inputs: a template can declare the merge values its sending app sends (inputs: a label, where each comes from, its kind, text, url, boolean or one_of with labelled values, and an example) through templates.declareInputs (new in the SDK and the service's PUT /v1/templates/:id/inputs, API keys only); inputs_seen records the merge names real sends actually carried. The contract's templateInputProblems and templateInputDrift give stable reasons for a bad declaration and for a mismatch between what is declared and what is sent.
  • The insertions surface, as data mail-react's insertionRows shapes by the declared kind of the value an insertion depends on (Yes/No, Has a value/Is empty, a row per declared value, then Anything else and Not given), plus exampleControls, pruneExamples, effectiveExamples and readToken (what a token reads on the canvas: its text, empty, missing in the open language, or its label with examples off); useExampleValues keeps an author's example values per template in the browser (inside try/catch).
  • useInsertionsSurface: gives a template editor the editor's token seam (chips with real text, the Insert menu, the Inspector slot, New insertion), an Insertions tab, the example values control for the toolbar and the dialog to render beside the editor. TemplateEditor adopts it in place of its own InsertionsPanel (removed): the subject and preheader gain the Insert menu and a "Reads as:" line, and the Preview pane renders with the same example values.
  • Renaming an insertion now changes every place it is printed, in the body, subject and preheader of every language, in one edit: the contract's renameInsertionEverywhere plus the editor's new renameToken (X2 above) applied to the open document as one undoable step. The Inspector says where and offers Undo rename.
  • mail-react's shipped stylesheet now doubles the first class of every .mr- selector at build time, so the editor's scoped CSS reset no longer strips the Inspector's buttons and fields once TemplateEditor sits inside it.

[0.6.1] - 2026-09-25

The mail set only (mail-v0.6.1: contract, SDK, mail-react, which move together); the SDK is unchanged, the contract only exports a helper. Slice X0 of docs/plans/2026-09-25-insertions-ux.md.

Fixed

  • mail-react's TemplateEditor no longer lets a host-kept draft hide or delete a template's insertions. A draft kept before the template gained insertions (or before they changed) carried insertions: {}, which was laid over the loaded template whatever version the draft was made on: the panel read "none yet", the canvas showed raw tokens, and "Save mine over it" deleted the insertions in every language. The ŌPUNTIA Studio, which keeps drafts in the browser, hit this. A draft now keeps insertions only when they differ from the loaded template's (the rule a save already applies) and records the version they were edited against (TemplateDraft.insertionsVersion, new and optional). On restore, the draft's insertions and every language's texts for them are laid over only on that same version; otherwise the template's are kept and the restore notice says so. A language only the draft has gets none of its texts back in that case (an insertion of the same name may mean something else now): they are set aside, and a ready version goes back to draft until they are written again. The same holds for any language restored from an older version that lacks a text for the template's insertions (it held texts for none, or was kept before insertions existed), since ready it would be refused on save. A draft kept by 0.6.0 is compared by its baseVersion.
  • The contract exports sameJson (equal as JSON with keys in any order), which it already used to compare insertions, so mail-react compares them the same way.

[0.6.0] - 2026-09-25

The mail set only (mail-v0.6.0: contract, SDK, mail-react); the editor set stays at 0.4.1. Translated insertions (docs/plans/2026-09-25-translated-insertions.md), asked for by Sharon for ŌPUNTIA's visitor confirmations: the client sends data, never prose; every sentence a reader sees lives in the template, in the reader's language. #105 (the contract and the service) and #106 (the insertions panel in both editors). The service and the dashboard deployed with each merge; migration 0028 ran on the service's start.

Added

  • A template holds named insertions (insertions): sentences chosen per recipient by one merge value (by), with a text per value (choices), an optional text for any other filled value (filled) and a required otherwise text. Each language version holds its own texts (translations[tag].insertions), and a ready version needs every one (ready_needs_insertions). A body, subject or preheader places one as a plain {{name}}; an insertion wins over a merge value of the same name. A choice added in the main language sets ready languages without its text back to draft (named in the audit entry). Save refusals: insertion_name, insertion_by, insertion_text, insertion_with_fallback, unknown_translation_insertion. templates.list names each template's insertions (insertion_names).
  • Sending: a mailing takes the template's insertions with its document; each ready language it starts with carries its own texts (a language whose texts do not cover the mailing's insertions is not sent, its readers get the main language whole). Automation email steps and signup confirmations snapshot them the same way. Every archived message records the choice each insertion made (Message.insertions, also on message.sent), and mailings.languages reports values no choice takes (insertion_misses). A test send of a draft marks a text not written yet.
  • A paragraph made only of merge fields and insertions that all come out empty is removed, and a text block left with nothing visible is removed with its row and padding: no "Dear ," and no gap.
  • The contract's insertions module: the schemas, chooseInsertion, resolveInsertions, personalizeHtml and personalizeText (the one rendering the send worker and the editors' previews share), insertionCoverage, alignTranslationInsertions, languageInsertions, insertionInputs (what a client sends, for a form's fixed choices) and the checks at save; insertion drafts for the editors in template-languages.ts (editMainInsertions, renameInsertion, renameInsertionChoice, editInsertionText, missingInsertionTexts, readyWithoutInsertions, demotedBySave).
  • Both template editors (the dashboard's and mail-react's TemplateEditor) get an Insertions panel in each language tab. The main language adds an insertion (a name and the value that chooses it), adds, renames and removes choices (a rename carries every language's text), turns "any other filled value" on or off, writes each text, and copies an insertion's token with "Copy {{name}}". Another language writes its texts beside the main language's, a text not written yet marked, and its tab counts them ("2 missing"). "Mark ready" waits for every text; a save that sets ready languages back to draft names them and what they lack in an in-page dialog and goes ahead only on confirmation; what the service would refuse is shown inline and stops the save; an insertion placed nowhere in a language is a warning. The preview gets a bar with one control per value an insertion reads (each choice, "any other value", "empty") and shows the open language, subject and preheader included, as a recipient with those values gets it. A mail-react host-kept draft carries the insertions. The shared helpers are the contract's insertion-editing module (addInsertion, renameInsertionChecked, saveDemotions, demoteForSave, draftInsertionProblems, previewControls, previewPersonalization, insertionRefusalHint and others).
  • Migration 0028_insertions.

Changed

  • A signup confirmation's own subject is now personalised like its body (merge fields and insertions); before, a {{name}} in it was sent as written.
  • A subject line's runs of spaces collapse to one after personalisation.

[0.5.0] - 2026-09-25

The mail set only (mail-v0.5.0: contract, SDK, mail-react); the editor set stays at 0.4.1. Templates in several languages (docs/plans/2026-09-24-template-languages.md): #98 (the model), #99 (sending), #100 (automations and signup confirmations) and #101 (the editors' language tabs). The service and the dashboard deployed with each merge; migrations 0025, 0026 and 0027 ran on the service's start.

Added

  • A template has a main language (locale) with its own subject and preheader, and one version per other workspace language in translations, each draft or ready. templates.update saves one language at a time; every saved version holds all languages together. templates.compile and templates.export take a locale; templates.list names each template's languages and their status (migration 0025_template_languages, which gives existing templates their workspace's default language).
  • LocaleTag, canonicalLocale, primaryLanguage and matchLocale in the contract, for language tags compared without regard to casing.
  • Language tabs in both template editors (the dashboard's and @marlinjai/mail-react's TemplateEditor): the main language first, then each workspace language as ready, draft or missing, then versions kept for a language the workspace no longer lists (removable only). Each tab keeps its own unsaved edits, subject and preheader; "Add" starts a version as a draft copy of the main language; "Mark ready" (which needs a subject) and "Set back to draft" say what they mean for recipients; removing asks first. A save sends only the languages that changed, and preview, the server's asset check and export act on the open tab.
  • The dashboard's history previews each language of a version and restores all of them together, except a version in a language the workspace no longer lists, which the service will not write: the restore leaves it out and says so before and after. Duplicating a template copies every language the workspace lists (a left-out one is named in the copy); the template lists (dashboard and TemplateList) show each template's languages, and TemplateList previews any of them.
  • languageTabs, languageName, buildTemplatePatch, restorePatch and the language drafts (draftsFromTemplate, addLanguageDraft, draftsPatch and the rest) in the contract, the one definition both editors build their tabs and saves from.
  • Sending in each recipient's language: a mailing started from a template compiles the template's ready versions beside its main content, and the worker gives each recipient the version for their language (the mailing's send_locale, else merge.locale, else the contact's), the main language to anyone else. mailings.languages reports who gets what before the send and what went out after it; mailings.test takes a locale; every archived message records its locale (migration 0026_mailing_languages). A mailing's subject and preheader come from its template when the template has them. An A/B test needs a mailing in one language. The dashboard's send dialog and mailing page show the breakdown, the composer offers one language for everyone, and the test send picks a language version.
  • An automation's email step from a template sends in each contact's language: publishing snapshots the template's ready versions (locale and languages on the step) and compiles them beside the main content; the template's subject wins, so a step needs none of its own then. automations.testEmail takes a locale. The dashboard's step panel shows the template's subject and its language versions and tests each one.
  • A signup form's custom confirmation is compiled per language when the form is saved (migration 0027_signup_confirmation_languages) and goes out in the language of the signup, subject and body from the same version.

Changed

  • The workspace's default_locale and locales must be language tags (en, pt-BR), none twice; the service stores them in canonical casing.
  • The dashboard's workspace settings call the languages "Workspace languages" and "Main language", and say what they now do.
  • TEMPLATE_EDITOR_OPERATIONS and TEMPLATE_LIST_OPERATIONS include workspace.get, for the workspace's languages. A bridge that does not forward it gets the main language only, with a note saying why.
  • mail-react's TemplateDraft carries subject, preheader, and the language versions added or edited (translations) and removed (removed); a draft without them restores as before.

Fixed

  • A signup to a form with a custom confirmation template got the built-in subject in the language of the signup over the template's body in its own language (a German subject over an English email); both now come from one language version.

[0.4.1] - 2026-09-23

The editor set only (editor-v0.4.1: core, blocks, ui, editor). The mail set stays at 0.4.0.

Added

  • The colour of a selection: the formatting toolbar in the inspector colours the selected words of a Text block (the theme's swatches on a press, or any colour from a picker), as a <span style="color"> every email client reads. Bold, italic, underline, strikethrough and links already worked on a selection; the hint under the toolbar now says how to get one (double-click the text on the canvas, then select).

[0.4.0] - 2026-09-23

Both sets: editor-v0.4.0 (core, blocks, ui, editor) and mail-v0.4.0 (contract, SDK, mail-react). The editor's canvas is now the compiled email (docs/plans/2026-09-22-canvas-on-the-compiled-email.md), and mail-react's TemplateEditor feeds it the server's policy verdict, so the two sets move together again.

Added

  • @marlinjai/email-editor-core/browser: compileInBrowser, the same compile as the server entry over mjml-browser, for the editor's canvas only and display only; MjmlBuilder, the pure MJML builder both entries share, is exported from the default entry with stripEditorMarkup, textInlineStyles and the EDITOR_MARKER fences. The server compiler accepts { editor: true }, which emits fenced affordances for the canvas (ghosts for hidden nodes, a floor in empty columns, id markers around Raw blocks) that strip back to the plain document. Sub-columns and the blocks inside them carry el-<id> classes like every other node.
  • The canvas (@marlinjai/email-editor-ui, CompiledCanvas): the document compiled in the browser on every change, rendered in a frame that runs no scripts, with selection, hover, node actions, drop targets and in-place text editing on an overlay. Mobile is a real 375 px frame. The pre-built section cards compile their thumbnail the same way.
  • policyErrors on EmailEditorReact, createEditor (and setPolicyErrors on its instance) and the ui EmailEditor: the messages of the host's last server compile, shown as a badge in the toolbar. The hosted dashboard and TemplateEditor compile on the server three seconds after the last edit and feed it.
  • The example app opens a saved template by id (/editor?id=<name>).
  • A full-screen button in the editor's toolbar: the editor covers the whole viewport, above the host's own page, until the button or Escape ends it (Escape first clears a selection, as before). CSS only, so it needs no browser permission and works inside a host that boxes the editor into a short area.

Fixed

  • Undo with the Layers panel open no longer throws in a development build: the panel's rows took store instances as props, which React's development render logger walks after every render, dead ones included. They take ids now and resolve their node from the store on each render.
  • A container's "Gap between sections" now renders: MJML's mj-wrapper has no gap, so the compiler adds it to the top padding of every section after the first inside the wrapper (MJML's default 20px when the section declares none). The export still carries gap on mj-wrapper and the import takes it off the sections again, so a document reads back as it was written. Before, only the old React canvas drew the gap; the sent mail never had it.

Changed

  • Core is on mjml 5: mjml, mjml-browser, mjml-parser-xml and mjml-preset-core are pinned to exactly 5.4.1, and a test keeps the four equal. mjml 5's mjml2html is asynchronous, so MJMLCompiler.compile, compileInBrowser, importMjml and exportTemplate return promises now (await them; the service's compile worker does). mjml 5 has no html-minifier, so GHSA-pfq8-rq6v-vf5m is gone from every install of the editor. The compiled HTML is what it was: the nine-document corpus, the hand-written MJML references and the browser parity tests pass unchanged.
  • The compiler's per-block MJML generation lives in MjmlBuilder; MJMLCompiler extends it with the same public API.

Removed

  • The React block renderers of @marlinjai/email-editor-ui (EmailRenderer, SectionRenderer, ColumnRenderer, BlockRenderer, the per-block components) and their exports; the canvas is the compiled email. Zoom (zoomLevel, zoomIn, zoomOut, setZoomLevel, resetZoom, zoomPercentage on EditorUIStore) is gone with the fake layout it scaled.

Added (mail set)

  • The product's privacy notice at mail.lumitra.co/privacy (/privacy/en, /privacy/de; the page negotiates German or English), in the landing page's frame and under its policy: the processor role, what is stored for recipients and for operators, where (Hetzner in Germany, Storage Brain on Cloudflare R2, the workspace's own provider), retention, erasure and the rights. The landing footer's privacy link points at it. A draft of the Art. 28 processing agreement is docs/public/processing-agreement.md; both await legal review.

  • Letters: a mailing created without a topic (or a draft updated to topic: null) is one-to-one mail from a client's backend (docs/plans/2026-09-21-topicless-letters.md). It holds one recipient (MAX_LETTER_RECIPIENTS; more, a segment audience or an A/B test is validation_failed with details.reason: "letter_has_one_recipient"), is stopped only by a block on every topic, carries no List-Unsubscribe headers, and its {{unsubscribe_url}} opens the hosted page with "unsubscribe from everything" first; using it blocks every topic and emits contact.unsubscribed with topic: null. Service migration 0024 allows a null topic on any mailing but an automation's step.

  • topics.delete (DELETE /v1/topics/:id, admin): removes a topic nobody sends to; refused with conflict while a mailing or an automation step still uses it, and a subscription or suppression on it goes with it. mail.topics.delete(id) in the SDK.

Changed (mail set)

  • Mailing.topic and MailingSummary.topic are string | null, and MailingUpdate.topic accepts null. A client on contract 0.3.0 fails response validation on a letter, so it must move to 0.4.0 before a letter exists in its workspace.

[0.3.0] - 2026-09-21

Both release sets move together: editor-v0.3.0 (core, blocks, ui, editor) and mail-v0.3.0 (contract, SDK, and the new mail-react), since mail-react imports from the editor package what only 0.3.0 exports.

Added

  • @marlinjai/mail-react, a new package in the mail release set, for Lumitra Mail inside a client's own app (docs/plans/2026-09-21-embeddable-mail.md, slice E1). Its /server entry is createMailBridge(client, { allow, onBehalfOf }): a handler over the web standard Request and Response that a Next.js route handler or any adapter mounts in one line. It forwards only the operations in allow (refusing at mount an operation a key can never satisfy and the two file exports), validates params, query and body against the contract's own schemas before anything reaches the service, passes no browser header through, and answers with the service's own error envelope (with details.source saying which side refused). READ_OPERATIONS is every read operation the bridge can forward. Every request must carry the x-mail-bridge header, which an HTML form on another origin cannot set and which makes a cross-origin fetch preflight, and a request the browser labels sec-fetch-site: cross-site is refused, so a page on another site cannot post to the bridge with the host's session cookies. The default entry is the browser side: createBridgeTransport({ url }) posts to the bridge, mints an idempotency key for a mutating call, and throws MailBridgeError. Both uploads (assets.upload, imports.create) go through as multipart.

  • @marlinjai/mail-react/views (slice E2): TemplateList, every template of the workspace live from the service with its version, when it changed, the last mailing that used it and how many it sent (computed client-side from the newest mailings, since a mailing carries its template_id), archived ones behind a toggle, and a preview of the saved version compiled on request into a sandboxed frame, cached per version; and TemplateEditor, one template in @marlinjai/email-editor (a peer dependency, loaded after mount) with the workspace's saved sections, "Save as a section", a preview of the unsaved document that says when it went stale, an image dialog that uploads to the workspace's assets unless the host passes its own picker, and a save on the loaded version whose conflict is the same decision the hosted dashboard offers (load theirs, save mine over it, decide later). draft and onDraftChange carry unsaved work through a page reload on the version it was made on. MailProvider hands the views their transport and theme; the views read the editor's --ee-* tokens so one theme covers both, and styles.css loads nothing from anywhere, which a test asserts. TEMPLATE_LIST_OPERATIONS and TEMPLATE_EDITOR_OPERATIONS are what a bridge behind each view allows, exported React-free from . and /server.

  • @marlinjai/mail-react/views (slice E3): three read-only views. ContactList, the workspace's contacts with the route's own filters (an exact email address or external id, a topic) and paging; ContactDetail, one contact with the blocks on its address and their reason (from suppressions.list, shown and never editable), the messages it received with each one's final HTML on request, and its automation runs; SentArchive, every message the workspace sent, filtered by outcome or mailing, each openable as it went out. Every message opens in a frame that runs nothing and fetches nothing. The empty states point at the hosted dashboard for what these views do not do. CONTACT_LIST_OPERATIONS, CONTACT_DETAIL_OPERATIONS and SENT_ARCHIVE_OPERATIONS for the bridge.

  • Mail contract and service (slice E4): three webhook events, template.created, template.updated (with the version after the save and whether it archived) and template.deleted, emitted in the same transaction as the write by every user interface that makes one (a key's call, the dashboard, an MJML import, which the event's source says), never carrying the document. Migration 0023 widens the three constraints that enumerate event types. The dashboard's webhook form labels them.

  • @marlinjai/mail-react (slice E4): the handoff. mailHandoffUrl({ workspaceId, kind, id? }) builds a link into the hosted dashboard for everything the views leave to it (automations first, then mailings, a template's history, imports, members, keys, providers, billing and the rest of HANDOFF_KINDS), refusing an unknown kind or a missing id where the link is written; HandoffButton in /views renders it. A test checks every kind against the dashboard's own page files, so a link cannot point at a page that does not exist.

  • Docs: docs/public/embedding.md, the guide to embedding Lumitra Mail in a client's own app (slice E5): the SDK on the server, the bridge and its three rules, the views, drafts through a reload, the template events, and the four paths of the edit loop a host should test.

  • Editor package (@marlinjai/email-editor/react): useEditorDocument, toStoredDocument and EDITOR_CHANGE_DEBOUNCE_MS, lifted from the dashboard so every screen that embeds the editor shares the two rules that lose work when wrong (the mount report is a baseline, not an edit; read only after the debounce settles); replace() without touched now also resets the interaction flag, so an editor remounted on a reloaded document does not read its own baseline report as an edit. themeToStyle and EmailEditorReactProps are exported. Core: DEFAULT_ON_CHANGE_DEBOUNCE_MS names the store's default. The dashboard re-exports the hook from there.

  • Mail contract, SDK, service and dashboard: x-mail-on-behalf-of, an optional label on a key call naming the person the client's application acts for (trimmed, at most 200 characters, longer is invalid_request). The service records it on the audit entry's actor as on_behalf_of, still an api_key actor, and ignores it on a dashboard call; the dashboard's audit log shows it as reported by the client's application. It never changes what the key may do. The SDK sends it from RequestOpts.onBehalfOf on the JSON and the multipart path, and gains client.upload(operationId, args) beside client.request for a caller that dispatches generically. The contract gains operationsWithAccess(level).

  • Mail service and contract: two workspace settings the first client needed. allowed_asset_hosts lists hosts a service_only workspace also lets its mails load images, stylesheets and fonts from, meant for the workspace's own website, so a sender's own images need no copy into the service; matched exactly and case-insensitively, at most 20, ignored under any. The compile errors, the import preview's remote_images and the import's remote-image copy all honour it. merge_defaults holds merge values every mail of the workspace can use, resolved after the recipient's values and the contact's properties and before a field's {{name|fallback}}: the operator's legal name and address, the privacy policy's address, the things that are the same in every mail. Keys are merge-field names, the four reserved ones are refused, values are scalars, at most 50; an update replaces the map. The audit log records the hosts from and to, and for merge defaults the keys that changed, never the values.

  • Dashboard: both settings on the workspace's general settings page; the hosts under the asset policy, shown when it is service_only, and the merge defaults as key and value rows with the service's rules checked before the round trip.

[0.2.0] - 2026-09-20

Two release sets, tagged and published together: editor-v0.2.0 (@marlinjai/email-editor-core, -blocks, -ui and @marlinjai/email-editor) and mail-v0.2.0 (@marlinjai/mail-contract, @marlinjai/mail-sdk). The first versions of all six on npm.

Added

  • Saved sections. A workspace builds a footer, a header or a signature once and drops it into any other template, which before this it could not do at all (duplicating a whole template or exporting MJML was the only reuse there was). A section is stored under a name, unique per workspace, up to fifty of them; four routes (savedSections.list, .create, .update, .delete) and a matching SDK namespace; a "Save as a section" action on a section's own controls on the canvas, which asks for the name in the editor's own dialog and refuses one already taken before the save is attempted; the workspace's sections in the picker under their own group, above the built-in ones; and a management list under Templates for renaming and deleting. It is a stamp, not a live partial: inserting copies, and editing the saved section afterwards changes nothing that already holds it. A live partial is a different feature and has its own plan (docs/plans/2026-09-20-live-sections.md), which decides the four questions it raises rather than leaving them open.
  • Two editor options, both optional, so a host that passes neither gets exactly the previous behaviour: savedSections with onSaveSection, and builtInSections (true by default) to turn the thirty-five sections that ship with the package off for a host that brings its own library.
  • Placeholder images served from the host's own origin. placeholderBase on the editor points every built-in placeholder at <base>/p/<width>x<height>.png, which the mail service now serves: a flat grey rectangle at that exact size, public and unauthenticated beside /a/:id. Without the option each placeholder stays an inline data: URI, which renders in the canvas and needs nothing behind it. See "Placeholder images" in packages/editor/README.md.
  • A3 S4, the tracking-gated triggers and conditions. segment_entered fires when a contact starts matching a segment: a segment is a filter evaluated on read, so nobody joins one and no transaction can write the entry, and a sweep diffs against a per-trigger membership snapshot instead (migration 0021). The snapshot is seeded inside the publish transaction on every publish, without which going live would mail every contact who already matched. link_clicked fires on a click on a named mailing, optionally on one address, written in the same transaction as the tracking row. And step:opened and step:clicked ask what the mail this run sent at a named email step did, answered across every visit to that step, which is not the same question as engagement:.
  • A3 S3, the webhook step and the internal notification. A step that calls one named endpoint of the workspace, and a step that mails a colleague, which is the one send in the service that is deliberately not marketing: no topic, no unsubscribe link, not subject to contact suppression.
  • workspace.move, POST /v1/workspace/company: hands a workspace to another company the signed-in owner holds. A new owner access level whose list of acceptable API key scopes is empty on purpose, because a workspace API key is scoped to one workspace and must never hand it to somebody else.

Automations

Flows a person walks through step by step, with durable per-contact state in the service's platform worker. Researched against MailerLite; the plans are docs/plans/2026-09-19-automations.md (completed) and docs/plans/2026-09-20-automations-a3.md (in progress).

  • The flow document in @marlinjai/mail-contract: triggers, steps and settings as Zod schemas, the filter language automations share with segments (plus segment and event:<key>, which a segment cannot use), and validateAutomation, which the canvas and automations.validate both run so a flow is refused in the same words wherever it is checked.
  • The runtime: automations, automation_versions, automation_triggers, automation_entries, automation_runs, automation_step_executions and events, swept by a platform job that admits one entry, settles one email and advances one run per tick, each in a savepoint with five attempts and a backoff from thirty seconds to an hour, so one poisoned row cannot stall a workspace. Triggers are recorded in the transaction that makes the change they observe, never guessed afterwards.
  • Email steps are long-lived mailings, so every send-time rule (consent, suppression, the provider's budget, the bounce breaker, the plan's message limit) and all the existing analytics apply unchanged, and there is no second send path to keep in step.
  • Twenty routes, including POST /v1/events for a client's own events, automations.enroll, automations.publish with a dry run that says who would move, and GET /v1/contacts/:id/automations.
  • The dashboard: the list, a canvas drawn from a computed layout (React Flow renders, it never decides), a side panel per step kind, validation as you edit, publishing with its preview and optional backfill, the report per step and the journey per person. The email step opens the editor over the whole window; what comes out belongs to that step alone, and the panel says so.
  • Date and anniversary triggers: date_anniversary fires every year on a date the contact carries, moved by an offset, at a wall clock in that person's own time zone; exact_date fires once for everyone a filter names. Delays gained date and date_property, each saying what to do when the date has already passed. A scheduler job sweeps for both, keyed so that a republish on the same day cannot enter anybody twice.
  • The A/B split divides arrivals between two and four weighted branches. Which branch someone takes is a hash of the run and the step rather than a draw, so a worker that crashed between deciding and writing reaches the same answer when it returns. Gated on the existing ab_testing plan feature.
  • Move to step, which makes a flow something other than a tree, with two guards in different places: the validator refuses a loop with nothing in it that waits, and the runtime ends a run that reaches one step more than twenty times, saying so on the journey.
  • Conditions can wait: a condition whose filter is false holds the run and is rechecked as the worker comes round, taking a named branch when the window is up.
  • A workspace default time zone, which is what delays and quiet hours fall back to for a contact who has none.

Editor, MJML and the mail service

  • Editable containers (MJML mj-wrapper): several sections under one background (colour, gradient or image), border (all sides or each side), corner radius and padding, with a gap between the sections, full width, css-class and text-align. Add one from the Layout tab or wrap a section (canvas toolbar, inspector, Layers panel); unwrap it; drag sections into, out of and between containers in the Layers panel (pointer or keyboard), where a container's sections are nested one level in. The canvas draws the container as the mail does, with its own selection ring and handle; the inspector edits every attribute, the background image through onRequestImage (blockType: 'wrapper'). Deleting a container asks in the editor's own dialog whether to keep its sections. Every container action is one undo step.

  • Schema 1.1: a document's top level is an ordered list of sections and wrappers (Wrapper, TopLevelItem, isWrapper(), allSections() in the core; WrapperModel, createWrapper, isWrapperInstance and the template's addWrapper, wrapSection, unwrap, moveSectionTo, removeWrapper, duplicateWrapper in the store). migrateTemplate takes a 1.0 document to 1.1 changing only the version, except that the 1.0 isWrapper flag becomes a real wrapper; a 1.0 document without it compiles byte-identically.

  • The MJML import maps mj-wrapper to a wrapper instead of Raw HTML, with every wrapper attribute as a field; children it cannot read as sections stay inside, in place, as raw HTML.

  • Keyboard: Delete removes a selected section, and asks before removing a selected container.

  • importMjml in @marlinjai/email-editor-core/server: reads an MJML document into the editor's document model. Nothing is dropped silently: what cannot become an editable block is kept as compiled HTML, with a warning carrying its path and source; unreadable MJML is an MjmlImportError with a line and column; mj-include is refused. The editor's own export imports back exactly.

  • Documents keep MJML attributes the editor has no control for (extraAttributes on blocks, columns and sections) and an imported document's head settings (metadata.mjmlHead); the compiler emits them.

  • Mail service: templates.importPreview, templates.import (idempotent, with an optional copy of remote images into the workspace's assets), templates.export and mailings.export (MJML or HTML files, never refused; send-blocking problems in x-mail-export-warnings), and the invalid_mjml error. The contract and the SDK carry all four.

  • Dashboard: "Import MJML" (paste or upload, preview, warnings, remote images, create) and an Export menu (MJML or HTML) for templates and mailings.

  • Landing page: the editor line names MJML import and export, in all five languages.

  • Editor: the section inspector warns when its columns add up to more than 100% ("These columns add up to 110%, so the last one wraps below on desktop."), counting a column without a width as MJML does (100 divided by the number of columns).

Changed

  • A placeholder image now stops a send. A compile reports one as a warning, which the dashboard shows in the preview, and the two places a send is committed refuse it: a broadcast at start-mailing and a flow at the automation publish, both mailing_not_ready with reason: placeholder_image. The same shape as the missing unsubscribe link, and for the same reason: fine in a draft, refused at the door. Before this a forgotten placeholder went out as a grey box, or, while placeholders were inline data: URIs, as an invisible gap in Gmail and Outlook.com while the sender's own test looked fine.

  • The automations validator asks the kind of tracking a filter actually needs rather than one master switch. A workspace that records opens but not clicks was accepting an engagement:clicked filter that can never be true; it is now refused, and the filter builder offers each engagement field by the tracking it needs. The same correction applies to the segment builder.

  • The dashboard's end-to-end suite declares its order instead of relying on file names: dashboard.spec opens on an empty database and walks a new person through creating their first workspace, so every other spec now depends on it through a Playwright project rather than by sorting after it.

  • Mail service: accepts schema 1.0 and 1.1, stores a document in the version it was sent in, and brings it to 1.1 for every compile. The contract's DOCUMENT_SCHEMA_VERSIONS is ['1.0', '1.1']. New documents from the editor and the dashboard start at 1.1. Upgrade order for hosts on an older editor. The dashboard writes 1.1 for every save, wrappers or not, and an editor on 1.0 refuses a 1.1 document with NEWER_VERSION ("upgrade the @marlinjai/email-editor packages"). A host pinned to ^0.1.0 does not pick up this release on its own, so upgrade it before anyone edits its templates in the dashboard. ŌPUNTIA is the one host affected today. Documents that host saves itself stay 1.0 and keep opening there, because the service stores a document in the version it was sent in.

  • MJMLExporter (server entry) compiles the store's snapshot with MJMLCompiler instead of its own copy of the compiler, so both give the same mail.

  • Inspector fields have labels tied to their inputs, and button groups say which option is pressed.

Removed

  • BREAKING (@marlinjai/email-editor-blocks): one client's brand is out of the built-in section library. The thirty-five sections were built for a single client and rendered that client's owner name, email address and website, so any workspace that inserted one mailed a third party's details to its own subscribers. The two locked block types compiled the same company name into every mail that held one, from the blocks package and from the server compiler both. All of it is gone. return-brand.ts is now section-defaults.ts and its exports are renamed: BRAND to PALETTE, CONTENT to PLACEHOLDER, and BRAND.bgCream to PALETTE.bgMuted. The palette is grey, the fonts are ones every mail client has, and the copy names nobody and holds no email address. The locked header block renders nothing at all now: it is hidden from the editor and carries no props, so anything it rendered was content its sender could not correct.

  • Every built-in image address. They were all placehold.co, as were the Image, Hero and Carousel block defaults. A workspace whose asset_policy is service_only refuses every address outside the service's own host at compile, so inserting one produced a mail that could be edited and never sent. They are data: URIs by default now, and addresses on the host's own origin when placeholderBase is given.

  • @marlinjai/email-automation, the old in-process automation engine: a DatabaseAdapter wrapper, an AutomationEngine the host called whenever it remembered to, and a React SequenceBuilder. The service owns automations now, so keeping both would have meant two answers to the same question. The package was never published, so nothing outside this repository can depend on it. The condition coverage its tests carried lives in the service's filter-parity battery, which asks Postgres and the in-memory evaluator the same question about the same people.

Fixed

  • The move confirmation promised an old company a workspace never had. The sentence is a pure function with an adoption case and a hand-over case.

  • The publish-time trigger indexer assigned into a pre-initialised variable in a switch, so a trigger kind missing from it was indexed under a null key and published an automation that could never fire. Both new kinds are handled and a never guard makes the next omission a build failure.

  • Four assertions in the end-to-end suite passed whether or not the thing they checked had happened: getByText matches a substring case-insensitively, so getByText('Saved') also matched "Unsaved changes" and getByText('Live') matched an option reading "Olive Owner". Every such assertion is exact now.

  • The A/B split end-to-end test has its own sixty second timeout. It is the slowest test in the service suite and timed out twice in contended full runs while passing alone, which was masking real failures.

  • A time zone this server cannot resolve is refused where it is written, on the workspace and on a contact, instead of being accepted and then quietly becoming Coordinated Universal Time at send time.

  • The editor asks for a link address in its own dialog rather than in the browser's prompt().

  • The topic picker reaches topics from where they are chosen, and the tab row keeps one height.

  • Dashboard: every topic picker (the mailing composer, a new automation) links to Settings, Topics, so a topic can be made without hunting for the page. The "no topics yet" note links there too.

  • Dashboard: the sub-tab rows (settings, contacts) no longer draw a scrollbar track inside themselves, so a row keeps one height. It still scrolls sideways on a narrow window, by wheel, touch and keyboard.

  • The rich text toolbar asked for a link address in the browser's own prompt box. It asks in the editor's dialog now, which knows the address the selected text already links to (so it edits rather than replaces), offers to remove the link, fills in https:// for a bare domain, and refuses any scheme other than https:, http: and mailto: with a message naming it. Escape, Cancel and the overlay leave the document untouched, and focus goes back to the toolbar button. The selection is remembered as character offsets inside the text block rather than as a Range, because the block rewrites its own content when it loses focus to the dialog, which would leave a Range pointing at nodes that are no longer in the page.

  • Redo never worked: undo recorded the state it restored as a new history step, which cut off the redo future.

  • The Image background mode of a section could not be chosen before an image was set, so no background image could be added from the inspector.

  • Duplicating a section gave its sub-columns and their blocks the ids of the originals; every id inside a copy is new now.

  • The editor now gives back exactly the document it was given. It dropped every padding object when it opened a document (and its own padding edits never reached the compiled mail), made every column without a width 100% wide, gave an untitled document the title "Untitled Template", added the fields of every block type to each block, and turned ISO date strings into numbers. The corrected output applies to documents that pass through the editor's store from now on. Templates saved through the old editor keep the width 100 and the title it wrote into them, and compile as before; no stored data is rewritten.

  • The compiler wrote only the padding sides that were set, so { top, bottom } was read by MJML as vertical and horizontal padding; it now writes all four.

  • Dashboard: the template editor showed two Save buttons, the page header's and the editor toolbar's own. The dashboard no longer passes onSave to the editor, so only the header's Save remains; Cmd/Ctrl+S still saves.

[0.1.0] - 2026-09-19

The editor becomes Lumitra Mail: a multi-tenant mail service and marketing platform, deployed at https://mail.lumitra.co (API, hosted pages, landing page) and https://app.mail.lumitra.co (dashboard), with ŌPUNTIA's Studio as its first client. Packages are versioned 0.1.0; their first npm publish is a manual step (scripts/first-publish.sh).

Added

Lumitra Mail service (apps/service)

  • S0 foundation: workspaces, members and invitations, hashed and scoped API keys, audit log, idempotency keys, typed error envelope; every route mounted from the contract's route table; Postgres migrations applied at container start.
  • S1 templates: versioned templates with conflict-safe saves, compile to MJML and HTML in a worker-thread pool, image assets in Storage Brain served from a stable /a/:id address, and an optional per-workspace policy that allows only service-hosted images and fonts.
  • S2 sending: SMTP and Resend providers with sealed credentials and per-provider policies (the iCloud+ limits built in), contacts, topics and suppressions, mailings with a state machine, a send worker (FOR UPDATE SKIP LOCKED, rolling daily budget, minimum interval, retries, outcome-unknown never resent), a hosted unsubscribe page in five languages with one-click unsubscribe (RFC 8058), and signed webhooks with retries and an SSRF guard.
  • Bounces and complaints: hard SMTP rejections and Resend bounce and complaint events suppress the address; a per-mailing and a per-provider circuit breaker stop mass false suppression and revert exactly.
  • S4 platform: tags, typed contact properties, segments compiled to parameterised SQL, resumable CSV imports, hosted signup forms with double opt-in, scheduling, A/B tests, and open and click tracking that is off by default.
  • S5 billing: plans and limits with usage metering, Stripe Checkout and portal on the shared account (fail-closed until configured), a signed Stripe webhook with reconciliation, and an operator-only billing exemption.
  • Company erasure through auth-brain's tenant.erased.
  • Public landing page at / in five languages, robots.txt and sitemap.

Dashboard (apps/dashboard)

  • Next.js 16 app at app.mail.lumitra.co, signed in through auth-brain with a second factor and the mail app grant; every screen over the SDK, server-side only: workspaces, members and invitations, API keys, providers, topics, webhooks, templates with the editor, mailings with live progress, the sent archive, contacts, suppressions, the audit log, the S4 marketing screens and billing.

Typed client (@marlinjai/mail-sdk 0.1.0)

  • Built on the contract's route table: retries with a stable idempotency key, typed errors, cursor pagination, response headers via onResponse, and webhook verification for receivers.

Editor packages (0.1.0)

  • React 19, Next 16 and Tailwind 4 hosts: a stylesheet scoped under .ee-root, the onRequestImage hook, migrateTemplate, documents without an id, and a checked Trusted Publishing release path for all six published packages.

Earlier work in this release

Mail service API contract (@marlinjai/mail-contract 0.1.0)

  • zod schemas and types for every v1 request, response and error of the mail service (workspaces, members, API keys, audit log, templates, compile, assets, providers, topics, contacts, suppressions, mailings, recipients, messages, webhooks), plus typed S4 (platform) and S5 (billing) namespaces.
  • A typed route table (operation id to method, path, schemas, access, phase), the mailing state machine as data, the error-code-to-status map, and webhook signing and verification over Web Crypto.
  • Documentation: docs/public/mail-contract.md.

Canvas UX (depth-2 nested columns)

  • A column can be split into 2-4 sub-columns via the inspector or merged back into a single column. Sub-columns hold leaf blocks only (text, image, button, divider, spacer, social); container blocks are filtered out of the palette while a sub-column is selected.
  • Sub-columns are first-class on the canvas: handle-on-hover at top-center, inset blue selection ring, dedicated inspector panel, layers-panel nesting one level deeper than columns.
  • Backspace deletes the selected sub-column. When the parent would be left with a single sub-column, blocks auto-merge back into the parent column.
  • Compiles to a sealed <mj-raw> HTML island wrapping a hand-built nested <table> inside the parent <mj-column>. The responsive media query (sub-columns stack to full width on mobile) is injected once at document head whenever any column in the template uses sub-columns. Targets the 85-90% client tier (Apple Mail + Gmail + modern Outlook); Classic Outlook for Windows is out of scope (retiring October 2026).
  • Spec: docs/superpowers/specs/2026-04-26-nested-columns-design.md. Tier rationale: docs/internal/email-client-rendering-landscape.md.

Phase 0 - Foundation

  • Marketing landing page at / with indigo-themed design
  • Editor moved to /editor route
  • Clearify documentation with public and internal sections
  • Cloudflare Workers deployment via OpenNext
  • Custom domain: email-editor.lumitra.co
  • Docs site: docs.email-editor.lumitra.co
  • Platform vision and roadmap for template management, campaigns, and analytics
  • Accordion block renderer (interactive expand/collapse preview)
  • Navbar block renderer (navigation link preview with hamburger icon)
  • Carousel block renderer (image slideshow with thumbnails and navigation)
  • Table block renderer (data table with headers and rows)
  • Header block renderer (branded/locked newsletter header)
  • Footer block renderer (branded/locked newsletter footer)
  • Vitest test infrastructure across core, blocks, and editor packages
  • 181 unit tests for core (schema validation, block registry, MST store, MJML compiler)
  • 20 integration tests for standard block registry (all 14 types)

Phase 1 - Template Management (@marlinjai/email-templates)

  • Template library dashboard with card grid, filters, and pagination
  • Template CRUD (create, duplicate, rename, delete, archive)
  • Template categories and tags
  • Version history per template with restore capability
  • Import/export templates (JSON + HTML)
  • Data Brain adapter for template storage
  • 39 tests

Phase 2 - Contacts & Audiences (@marlinjai/email-contacts)

  • Contact list management with bulk operations
  • CSV import with delimiter detection and field mapping
  • Segments with rule-based evaluation (10 operators, AND/OR logic)
  • Merge fields / personalization tokens ({{first_name}})
  • Unsubscribe management with signed tokens (CAN-SPAM / GDPR / RFC 8058)
  • 96 tests

Phase 3 - Campaign Builder (@marlinjai/email-campaigns)

  • Campaign creation wizard (template -> audience -> configure -> send)
  • Resend sending adapter (@marlinjai/email-send-adapter-resend)
  • Campaign scheduling (now, later, timezone-aware)
  • Send preview / test email
  • A/B testing (subject line variants, content variants, automatic winner selection)
  • Campaign status dashboard (draft, scheduled, sending, sent, failed)
  • Tracking pixel injection and click-wrap link rewriting
  • 51 tests (45 campaigns + 6 resend adapter)

Phase 4 - Analytics (@marlinjai/email-analytics)

  • Open/click/bounce/unsubscribe tracking with event ingestion
  • Click heatmap overlay with intensity levels
  • Per-contact engagement scoring with recency decay
  • Campaign comparison reports (side-by-side metrics)
  • CSV export for stats, events, and link clicks
  • Tracking pixel endpoint (1x1 GIF) and click redirect endpoint
  • 61 tests

Phase 5 - Teams & Workspaces (@marlinjai/email-teams)

  • Multi-user workspaces with roles (owner, admin, editor, viewer)
  • Approval workflows (request, approve, reject)
  • Template locking for approved templates
  • Audit trail with filtered queries
  • Brand kit (colors, fonts, logo upload via Storage Brain)
  • Workspace scoping for templates and contacts with permission checks
  • WorkspaceSwitcher component with role badges
  • 59 tests

Phase 6 - Automation (@marlinjai/email-automation)

  • Trigger-based email sequences (event, schedule, manual triggers)
  • Automation engine with enrollment tracking and step execution
  • Conditional logic evaluator (contact data, engagement, event data)
  • Step types: send_email, wait, condition (if/else), split (A/B)
  • Webhook receiver for external event integration
  • Visual sequence builder component
  • 50 tests

Dashboard UI Shell

  • SaaS dashboard layout with sidebar navigation
  • Dashboard routes: Overview, Templates, Contacts, Campaigns, Analytics, Automations, Settings
  • Mock data adapters for each route demonstrating component integration
  • Link to visual email editor from dashboard sidebar
  • Unified "Refined Midnight" visual identity across all surfaces

Shared Infrastructure (@email-editor/shared)

  • Data Brain and Storage Brain client factories
  • WorkspaceProvider, AuthProvider, PlatformProvider contexts
  • Paginated query hook
  • Schema bootstrapper for idempotent table creation
  • 12 tests

Changed

  • All packages renamed from @returnhypnosis/ to @marlinjai/ scope
  • Clearify dependency switched from local link to @marlinjai/clearify@^1.6.6
  • Documentation reorganized into docs/public/ and docs/internal/ sections
  • Mermaid diagrams enabled via client-side strategy
  • Sidebar files migrated from hardcoded gray/blue to token-based color classes
  • Example app upgraded to Next.js 16.1.6 and React 19
  • pnpm updated to 9.15.0, removed redundant npm workspaces config

Fixed

  • Dashboard build errors resolved with correct type shapes

[0.0.1] - 2026-02-10

Added

  • Initial email editor platform with MJML compilation
  • Core package: MobX State Tree state management, Zod schema validation, MJML compiler
  • UI package: 3-panel editor (sidebar, canvas, inspector), drag-and-drop via dnd-kit
  • Blocks package: 14 block types (8 with visual renderers), 35 prebuilt section templates
  • Editor package: high-level API (createEditor()) and React wrapper (EmailEditorReact)
  • API monetization and template registry support
  • Next.js example app with server-side MJML compilation
  • Rich text editing via TipTap
  • Undo/redo with Immer patches
  • Device preview (desktop/mobile)
  • Theming support via CSS custom properties
  • Clearify docs integration