EEmail Editor

Roadmap

Now

  • A mailing written from one language version, asked for by ŌPUNTIA (Sharon, 2026-09-28) for hand-written replies and outreach letters in the reader's language plan: mailings.create takes template_locale (main or translated, draft or ready), the mailing takes that version's content and sends it alone, in its language; the dashboard offers the choice. Service deployed with the merge; contract, SDK and React package in the mail set 0.10.0 (2026-09-28)

  • Release the mail set 0.9.0: #128 (archive, unarchive and delete) merged after review, and #129 (sort, resize and rename) carries the version bump, so its merge publishes mail-contract, mail-sdk and mail-react 0.9.0 through publish.yml (2026-09-28)

  • Deliverability of ŌPUNTIA's letters, from a mail-tester.com score of 8.3 (SpamAssassin's FONT_INVIS_MSGID, invisible text plus any ordinary Message-ID, and MIME_HTML_ONLY) plan: the preheader is one display:none element without font-size or opacity tricks, every send carries a text/plain part made from its final HTML, and the SMTP transport sends a Message-ID on the sender's domain and greets as mail.lumitra.co. Local SpamAssassin on the ŌPUNTIA template: 3.3 to 0.0 points. Service deployed with the merge; the core change rides the next editor release (2026-09-26)

  • List-Unsubscribe on letters, from ŌPUNTIA's confirmations junked by iCloud with a footer link as their only way out plan: letters carry the same RFC 8058 one-click headers as broadcasts, pointing at the footer's all-topics page; notifications and the signup double opt-in mail stay without them, on purpose. Service only, deployed with the merge (2026-09-26)

  • Insertions that belong to the editor, from Marlin's feedback on the first live use (ŌPUNTIA's confirmations) plan: the canvas shows real text in chips rendered with example values the author types, a template declares the inputs its sending app provides and where each comes from (one contract addition, synced from the app's code on deploy), the right-hand Inspector edits an insertion in plain-language rows, a left-sidebar tab lists insertions and inputs with per-language completeness, one shared component replaces the two duplicate panels, and a mail-react stale-draft defect that hides insertions in the ŌPUNTIA Studio is fixed first (X0). All slices X0 to X8 shipped and released (editor 0.6.1, mail 0.8.1; ŌPUNTIA live on them, opuntia-website #177, 2026-09-26), re-checked on the live Studio. Left, both parked by Marlin on 2026-09-26 as edge cases to leave for now: the VoiceOver pass (checklist in the plan's X8 note) and the Safari decision below; the plan closes with them (2026-09-26)

  • Templates in several languages, raised by ŌPUNTIA (Sharon, 2026-09-24) plan: a template has a version per language (its own subject, preheader and body, draft or ready), each send gives every recipient the ready version in their language and the main language otherwise, in mailings, letters, automation steps and signup confirmations, and both editors have language tabs. #98, #99, #100, #101; released as the mail packages 0.5.0. ŌPUNTIA's confirmations continue in opuntia-website's plan (2026-09-25)

  • Automations with a visual flow canvas and custom events, researched against MailerLite plan : A0 (the flow schema, its validator and the pure step evaluator), A1 (the tables, the platform job, the trigger hooks, the email-step send path and the nineteen routes), A2 (the list, the canvas, the side panel with the email editor over the window, validation, publishing with its preview, the report, the journey and a contact's own automations) and A4 (the old packages/automation removed) all shipped. The rest of MailerLite's set moved to its own plan, and commerce to a line of its own (2026-09-20)

  • Take one client's identity out of the built-in section library, which every workspace can see. The 35 built-in sections in packages/blocks/src/prebuilt/ were built for one client and rendered that client's owner name, email address and website, and the two locked block types (packages/blocks/src/branded/index.ts and the matching pair of private methods in packages/core/src/compiler/MJMLCompiler.ts) compiled the same company name into every mail that held one. All of it is gone: return-brand.ts is now the brand-neutral section-defaults.ts (grey palette, websafe fonts, placeholder copy naming nobody and holding no email address). Separately every image in the library, and the Image, Hero and Carousel block defaults, pointed at placehold.co, which a workspace with asset_policy: service_only (ŌPUNTIA has it) refuses at compile, so inserting one produced a mail that could not be sent; they are now data: URI placeholders, which that policy allows. packages/blocks/src/__tests__/no-third-party-content.test.ts walks the whole library and every block default and fails on either regression. What the built-in library should be instead stays a decision in the saved-sections plan below (2026-09-20)

  • 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) plan : all four slices shipped. S1 the table and four routes, S2 the two editor options and the "Save as a section" action with its dialog, S3 the management list and the editor page wiring with six end-to-end cases, S4 the built-in library behind builtInSections, offered by default, which the hosted dashboard leaves alone, so nothing a customer sees changed. Asked for by Marlin on 2026-09-20 and answered by him the same day: the built-in set stays as a per-host option, and it is a stamp (inserting copies; editing the saved section leaves existing templates alone). The live partial he wants eventually is a different feature and has its own plan below (2026-09-20)

  • Cache what continuous integration rebuilds every run. The two heavy jobs called each package's build script directly, so Turborepo never ran in CI and nothing was cached between runs; they now build through pnpm turbo run with .turbo/cache carried between runs (trimmed at a week by .github/actions/save-turbo-cache, since the 10GB repository budget is shared with the two image jobs), plus a Chromium cache keyed on the Playwright version and Next's compiler cache. Both heavy jobs now print their runner's cores and memory, and the first run answered that: 2 cores and 7GB, because GitHub gives a private repository half the runner a public one gets, so this repo halved its own CI capacity the day it went private. Raising the integration suite's worker count above that was tried and reverted in the same pull request: it did cut the suite from 215 to 173 seconds, and it failed the one test that asserts a wall-clock budget, because oversubscribed cores starved a compile past its 20 second limit. The reason is recorded in apps/service/vitest.config.ts so nobody retries it; that suite needs more cores, not more workers, and it is not on the critical path anyway (2026-09-21)

  • ŌPUNTIA's seven letters into the workspace: the first real content of the first SaaS tenant, from Sharon's PR #140 on the website repository plan : in-progress. Measured on 2026-09-21 that none of the seven can be sent from the workspace as they are (five service_only errors each, no {{unsubscribe_url}}, mj-include partials the compiler refuses), and that the service already resolves any merge field from the recipient and the contact, so what was missing was small: L1, two workspace settings (allowed_asset_hosts beside service_only, so the sigil stays on opuntiagatherings.com as Marlin decided, and merge_defaults for the legal strip), shipped as #68 and deployed; L2, the workspace configured through the dashboard in Marlin's session (host allowed, the three public URLs as defaults), with legal_operator and legal_address left for a person to enter since they are a name and a postal address; L3, the seven letters as templates, each created only after its import preview compiled with zero errors under the real policy, plus the masthead, the footer band and the legal strip as saved sections, all done the same day; the sigil is live through opuntia-website #141. L4's decision on the two one-to-one letters is made (Marlin: the link) and built as letters, the line below. The two personal defaults are in the workspace (legal_operator Sharon Di Salvo, legal_address Scherenbergstraße 22, 10439 Berlin, confirmed by Marlin and saved through the dashboard on 2026-09-23). Still open: the test sends; Sharon's #140 itself waits on its own three preconditions, reviewed there (2026-09-22)

  • Letters: one-to-one mailings with no topic, whose {{unsubscribe_url}} opens the hosted page and blocks the address on every topic plan : completed, #78, deployed and verified live on 2026-09-22 (a letter created in ŌPUNTIA's workspace, a test send whose body link opened the hosted page with the all-topics action first, then given a topic and cancelled so no letter stays there). Reading the code found no send had a null topic (a mailing could not be created without one), so the letter itself was built too: one recipient at most, stopped only by a block on every topic, no List-Unsubscribe headers. The npm publish of the widened contract is its own line under Leftovers (2026-09-22)

  • Lumitra Mail inside a client's own backend: how a client embeds the workspace's templates, an editor that saves bidirectionally, read views of contacts and the sent archive, and a button into the hosted dashboard for everything else (automations first) plan : completed, five slices; E1 built (the @marlinjai/mail-react package with its server bridge and browser transport, the x-mail-on-behalf-of label through contract, SDK, service and dashboard) and E2 built (TemplateList and TemplateEditor under /views, the editor's save-and-conflict rule shared with the dashboard through the lifted useEditorDocument, drafts through a reload, all four stateful-flow paths tested) and E3 built (ContactList, ContactDetail with the blocks on an address shown and never editable, and SentArchive, every message openable as it went out in a sandboxed frame) and E4 built (mailHandoffUrl and HandoffButton checked against the dashboard's own pages, and the template.created, template.updated and template.deleted webhook events with migration 0023) and E5 built (the embedding guide at docs/public/embedding.md; the release and the Studio rebuild are their own pull requests, below). Asked for by Marlin on 2026-09-21 after ŌPUNTIA's letters landed; ŌPUNTIA's Studio (Phases 2 and 3 of its studio-email plan) is the first client and the test. The recommendation: the published SDK and editor as the floor, plus a new @marlinjai/mail-react of prebuilt views over a server-side bridge that keeps the workspace key out of the browser, the service's existing base-version conflict rule reused rather than a sync, template webhook events and an on-behalf-of audit label added on the service, and a plain link into app.mail.lumitra.co instead of an iframe or a handoff token. Its three questions (the package name, the order, the on-behalf-of label) were all answered by Marlin on 2026-09-21 and the plan is decided: @marlinjai/mail-react, all five slices in order, the label kept as reported-not-verified. All six pull requests, #71 (E1) to #75 (E5) and #76 (the 0.3.0 release), merged in order on 2026-09-21, and both sets published at 0.3.0 the same evening. The first client is live: ŌPUNTIA's #136 (Studio mail Phases 2 and 3, with #142's template views on mail-react folded in, installed from npm at 0.3.0) merged and deployed on 2026-09-22 once the processing agreement was signed; its template list loads live through the bridge behind the production proxy, and a first one-person mailing from the Studio went out and was filed back by the webhook (2026-09-22)

  • Shard the Playwright end-to-end suite, which was 551 of the 724 seconds a pull request spent, on one worker in a declared order because the first-run spec needed an empty database and the shared owner plan : completed 2026-09-23. The first-run spec walks its own people, so the files run in any order and any subset alone; the first-run project is gone; CI runs the suite as four shard jobs, each with its own Postgres and stack, under a rollup that keeps the required check's name. A stack per job rather than a database per worker, because the sink, the plan setter and the service restart are one per stack. What remains is E4, the line below (2026-09-23)

  • The service job is the wall clock of a pull request now: 6 minutes against 4.5 for the slowest end-to-end shard, since the sharding (2026-09-23). Time its steps per the continuous-integration standard before changing anything; the integration suite on Postgres is the likely one (2026-09-23)

  • The dead-node error the Layers undo fix (below, under Leftovers) took out of the Layers panel still logs from another component: in a development build, the wrappers end-to-end spec's undo of an unwrap prints MobX State Tree's "no longer part of a state tree" for the sections that moved back into the container, dozens of times, logged and not thrown, so the spec passes. Same cause: React's development render logger deep-diffs the previous render's props, and something still takes a Section instance as a prop (the inspector's SectionProperties and the canvas's SectionActions are the two that do). Pass ids and resolve the node per render, as the Layers rows do, and assert no console error in that spec (2026-09-23)

  • Measure next start against next dev for the end-to-end suite (E4 of the sharding plan): the suite runs against next dev, which compiles each route on first navigation inside the measured time, because only a non-production process honours the test sign-in cookie; a build made with its own flag could honour it and be served by next start, while prod-guard.spec keeps proving the real production build refuses it. Run a shard both ways, put the two numbers in the plan, then decide (2026-09-23)

  • Two end-to-end specs fail intermittently, with no related code change: platform.spec.ts "import, resume and re-entry" (a toHaveText expect, main push run 36058138016 on e25abd2, a roadmap-only commit) and mjml.spec.ts "export from the editor" (page.waitForEvent('download') hit the 120 s test timeout, pull request run 36061465143, shard 2 of 4, where the same spec code had passed three times on the branch). A third on 2026-09-25: dashboard.spec.ts "settings: languages, a translated topic, an API key shown once, a webhook" got the root not-found page on /w/<ws>/settings/webhooks/<new id> right after the endpoint was created (run 36168863696, shard 1 of 4, on the 0.6.0 release, which changed only docs and versions; main had passed the same code an hour earlier), with nothing in the dashboard calling notFound(). Find the cause before any retry setting; next dev compiling routes on first navigation inside the measured time (the line above) is one suspect. The suite must pass shuffled and repeated (2026-09-25)

  • Translated insertions, asked for by Sharon for ŌPUNTIA's visitor confirmations plan: a template holds named insertions, sentences chosen per recipient by one merge value (a contact reason, whether a field was filled, whether a name is known), written and marked ready in every language; the client sends data, never prose. Lines made only of empty fields vanish, unmatched values are recorded, both editors have an insertions panel with a preview. #104 (plan), #105 (contract and service), #106 (editors); released as the mail packages 0.6.0. ŌPUNTIA's templates collapse in opuntia-website's plan (2026-09-25)

  • Live sections: a workspace marks one saved section live, and editing it then changes every template that holds it instead of only the copies made afterwards plan : draft, three slices, nothing built. Asked for by Marlin on 2026-09-20 as "both eventually", with the shape left to me; the plan is that call, written down for him to approve or overturn before anybody builds it. Live is a property of one section rather than of the feature, which is what MailerLite, Mailchimp, Brevo, Customer.io and Klaviyo all do. It decides the four questions the saved-sections plan left open rather than reopening them: a scheduled mailing keeps its snapshot, a template version stores a frozen copy beside the reference so it still reproduces its own output, a published automation step is frozen at publish, and deleting a referenced section is refused with the count and offers detach everywhere. One question left for him, which does not block the first two slices: whether making a section live should need more than write (2026-09-20)

  • The rest of MailerLite's automation set: date and anniversary triggers with a scheduler job, the A/B split, move-to-step, waiting conditions, the webhook step, internal notifications, segment entry, link-click triggers and engagement conditions plan : all four slices shipped. S1, the scheduler with the date and anniversary triggers and the two date delay modes; S2, flow control (the A/B split, move-to-step and waiting conditions); S3, the webhook step and the internal notification; and S4, the tracking-gated set (segment entry with its membership snapshot and its sweep, link-click triggers, and the step:opened and step:clicked conditions that ask what this run's own mails did). Each slice carries a "Corrected while building" block in the plan recording what building it showed the plan had wrong (2026-09-20)

  • Move ŌPUNTIA Gatherings, 9402caff-1afc-4875-b849-63a9dda9b35e, to the client's own auth-brain company. Done on 2026-09-20 through the dashboard, by Marlin, audited as workspace.company_changed with from: null. The premise of this line was wrong: the workspace belonged to no company, not to Lumitra, so nothing erased it automatically at all rather than the wrong thing erasing it. The correction does not change the action, since workspaces.company_id has exactly one consumer (auth-brain's tenant.erased) and the client's own company is the right answer either way. It now belongs to opuntia, which the client owns and which holds the mail app grant; their owner invitation was still pending when the move happened, as intended. The route it went through (workspace.move, owner only, no API key at any scope, the dashboard refusing any company the signed-in person does not hold with Lumitra Mail) landed the same day (2026-09-20)

  • Flaky service test: webhooks.test.ts, "rotation window: both the old and the new secret verify until the window expires". Failed once in a full local run on 2026-09-20 and passed alone, so it was order- or timing-dependent: it indexed the shared receiver's received[0] and [1], and expired the old secret one second back, racing the delivery. Since 2026-09-23 it finds each request by the event id header and expires a minute back (2026-09-23)

  • Flaky service test: automation-flow-control.test.ts, "the A/B split divides a crowd between the branches and counts each one". Timed out at 30 seconds in two separate full runs of the service suite on 2026-09-20 and passed every time the file ran alone. The timing settled it: the file takes 65 seconds under contention and this one test is most of it, so it is slow rather than hung, and the crowd of forty is load-bearing (the assertion that both branches get used needs a sample big enough for the split's real hashing to show). It has its own 60 second timeout now, which is the fix the line asked for once the numbers were in (2026-09-20)

  • Commerce for automations: shops, products, variants, categories, carts and orders as typed entities, their routes, the cart-abandonment job, commerce triggers and conditions, cart and order merge fields, then the Stripe connector and later WooCommerce and Shopify. Split out of the automations plan on 2026-09-20 because no customer has a shop yet; the research to start its plan from is the "E-commerce, events and data" section of the completed 2026-09-19 automations plan. Write the plan when a customer with a shop appears (2026-09-20)

  • The 0.2.0 release: the CHANGELOG entries for A3 slices S3 and S4, the four saved-sections slices, the section-library fix (breaking) and the placeholder work, the [Unreleased] section renamed to 0.2.0 with its date, and both release sets bumped from 0.1.0. Run with Marlin on 2026-09-20, through a pull request like every other change rather than a push to main. Not one tag: this repository releases two sets, so it is editor-v0.2.0 (core, blocks, ui, editor) and mail-v0.2.0 (the contract and the SDK), which is what scripts/release-sets.mjs and .github/workflows/publish.yml already expect. Marlin asked for the npm publish too, which is the line below (2026-09-20)

  • Placeholder images that a mail client actually renders and a service_only workspace actually accepts. The built-in sections and the image, hero and carousel block defaults need something to show before anybody picks a picture, and each obvious answer fails: an image host makes every recipient's mail client call a stranger and is refused at compile by service_only (so ŌPUNTIA could insert a section and never send it), and a data: URI passes that policy but Gmail and Outlook.com do not render one, so a forgotten placeholder is an invisible gap rather than an obvious mistake. The service now serves them itself at GET /p/<w>x<h>.png, public and unauthenticated beside /a/:id, and the editor takes the origin as placeholderBase (the data: URI stays as the fallback for a consumer with no service behind it). And because a placeholder still should not reach a subscriber, a compile warns and both places a send is committed refuse it: start-mailing for a broadcast and the automation publish for a flow, with mailing_not_ready and reason: placeholder_image, the same shape as the missing unsubscribe link. The locked header block, which is hidden from the editor and carries no props, renders nothing at all now rather than a placeholder its sender could not replace (2026-09-20)

  • A canvas that is the compiled email: the editor's canvas is a React re-implementation of every block and ignores the template-level theme (metadata.mjmlHead.attributes with its mj-class definitions and metadata.customCSS), so ŌPUNTIA's masthead and footer look flat while editing and only the compiled Preview is faithful. Marlin chose on 2026-09-22 to edit on the compiled output instead, as his Framer clone does: the real MJML output compiled in the browser by mjml-browser (same exact version as mjml, parity-tested), rendered in a frame, with an overlay for selection, drag and drop and in-place text editing plan : completed 2026-09-23. Decided by Marlin on 2026-09-22 with all five questions answered (400 KB gzipped budget, no fallback canvas, both hosts in one release, a policy badge fed by the server compile, zoom dropped) and built the same day on canvas-is-the-compiled-email: spike passed (353 KB gzip, p95 9.5 ms, byte-equal parity), core's browser entry with parity and bundle-guard tests, the canvas with in-place text, drag and drop and the policy badge, the React renderers deleted, both hosts wired; merged as #82 on 2026-09-23, with the wrapper gap, the Layers undo and the full-screen toggle (#83, #84) and mjml 5 (#87) on top. What is left is Marlin's: the editor set's 0.4.0 tag and publish, and ŌPUNTIA's Studio picking it up, both on the publishing line under Leftovers (2026-09-23)

  • A container's "Gap between sections" rendered nowhere in the mail: the inspector wrote gap on the wrapper and the export carried it as an mj-wrapper attribute, which MJML 4 does not have, so only the old React canvas ever drew it. Since 2026-09-23 the compiler adds the gap 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 for the round trip, and the import takes it off the sections again, so a document reads back as it was written; the compiled canvas shows the gap as the mail will (2026-09-23)

  • Undo with the Layers panel open threw in a development build. Found on 2026-09-22 while rewriting the wrappers end-to-end spec: the reader was React's own development render logger, which diffs a component's props between renders and walks objects deeply, and the panel's rows took MobX State Tree instances as props, so after an undo React walked the instances the undo had replaced (dead, and with child nodes never instantiated, which throws MST's dev-only assertion and then React's "Should not already be working"). Since 2026-09-23 the rows take ids and resolve their node from the store on every render, and the wrappers spec undoes with the panel open and asserts no page error. The old canvas read every block's props, which instantiated the child nodes and hid the throw (2026-09-23)

Leftovers (after the mail service build)

Decisions and operator steps that remain after the mail service plan was built (S0 to S5, the dashboard, bounces, the landing page; plan completed 2026-09-19).

  • Find why two service integration tests failed once under full parallel load: imports.test.ts "a batch that dies halfway is rolled back and resumed" and mailing-platform.test.ts "by opens: the variant with more human opens wins". Seen once during the insertions plan's releases, with no failure output kept. X8 (2026-09-26) tried about 20 runs of both files at 0, 5, 6 and 12 concurrent CPU hogs: 36 of 36 passed every time. Ruled out: the decide_at margin (31 minutes after a 30 minute wait holds whatever real time passed), the send timeout racing the memory transport, and the import claim's GREATEST(now, now()) floor, which is load-bearing (the Colima VM clock runs up to a second ahead of the host; removing it broke 7 of 12 import tests at once). PlatformWorker.drain stops at the first failed tick, so a backed-off import cannot be reclaimed inside the same drain. Leading guess for the A/B case: the shared compile pool (20 second budget, two workers per harness) timing out under load, so the mailing never sent; its send is now asserted with the response body, so the next failure names itself. Next time either fails, keep the output and the run's load. The same day one full local dashboard end-to-end run (all 117 cases) failed dashboard.spec "a restart of the service mid-send still finishes it": after the control server's SIGTERM the service logged "draining" and was not listening again for about two minutes, past the test's timeout (the next spec's setup then found no service); a run before and a targeted rerun passed. The graceful drain has no bound the test knows of: the same class, to look at together (2026-09-26)

  • Decide how in-place editing works in Safari (WebKit canvas listeners). Parked by Marlin on 2026-09-26 as an edge case to leave for now; when picked up, the recommendation is to keep the canvas sandbox and fix Safari's chip copy (it copies the shown text instead of the token). Found by the insertions plan's X8 run in a real WebKit (plan, X8): WebKit invokes no event listener on a node of a sandboxed document that may not run scripts, whichever document the listener comes from, and the canvas frame is sandbox="allow-same-origin" on purpose. So in Safari the editor's listeners inside the frame never run: Escape does not cancel an edit, Cmd+Enter does not end one, copying or cutting a chip puts the text it shows on the clipboard instead of its {{token}} (a paste then writes that text into the template), and guardNavigation does nothing. What still works: a press anywhere outside the canvas ends the edit and stores it (a listener on the editor's own document), typing, Backspace and arrows over chips, the Insert menu (X8 moved it to direct DOM insertion and a MutationObserver keeps chips atoms, both of which WebKit honours). Evidence: a synthetic keydown dispatched on the editable element reached no listener in WebKit and did in Chromium; with allow-scripts added to the sandbox every listener fired in WebKit too. Two ways out, for Marlin: (1) allow-scripts allow-same-origin plus a script-src 'none' Content Security Policy meta tag first in the frame's srcdoc, so the email's own scripts still never run while the editor's listeners do (weigh it against the frame's stated no-scripts posture in CLAUDE.md); (2) keep the sandbox and move those interactions out of the frame (keys through a hidden editor-document input, copy through the selection read from outside). The three affected cases are test.fixme for WebKit in apps/dashboard/test/e2e/insertions-chips.spec.ts (2026-09-26)

  • Publish both sets at 0.4.0 (editor-v0.4.0 and mail-v0.4.0, tagged on 2026-09-23 right after this bump merged; the publish workflow does the rest). The mail set carries letters' widened contract (Mailing.topic nullable, see 0.4.0 in CHANGELOG.md), the editor set the compiled-email canvas, full screen and mjml 5. Left for Marlin in the other repositories: ŌPUNTIA onto 0.4.0 (its in-memory fake and every read of a mailing's topic take string | null; its audit exception for html-minifier can go) and Studio picking up the new editor. Required before any client creates a letter: a client on 0.3.0 fails response validation on a null topic, so its mailings list stops loading. ŌPUNTIA's in-memory fake (tests/helpers/fake-mail-service.ts) and every read of a mailing's topic take string | null with that bump (2026-09-22)

  • Move @marlinjai/email-editor-core from mjml 4 to mjml 5, which drops html-minifier (GHSA-pfq8-rq6v-vf5m, a regular-expression denial of service with no patched release). Done 2026-09-23: the four mjml packages at 5.4.1, compile, compileInBrowser, importMjml and exportTemplate asynchronous, html-minifier gone from the lockfile, every compiler test passing on the same HTML as before. Ships with the editor set 0.4.0. Still to do, by Marlin in the ŌPUNTIA repository: drop the scoped audit exception that names this line once ŌPUNTIA is on 0.4.0 (2026-09-23)

  • Publish the seven packages at 0.3.0 (the six from 0.2.0 plus @marlinjai/mail-react, new in the mail set). Done by Marlin on 2026-09-21: scripts/first-publish.sh after npm login, the seven trusted publishers registered (the six existing ones already were, from 0.2.0; mail-react's was added through the automation Chrome), and the tags editor-v0.3.0 and mail-v0.3.0 pushed, whose publish workflow runs went green. Every version after this one is already CI: .github/workflows/publish.yml publishes a release set when its tag is pushed, authenticated by npm Trusted Publishing over OpenID Connect, with no token stored anywhere. What is not CI is the first version of each package, because npm configures a trusted publisher in a package's own settings page and a package that does not exist has none. So the bootstrap is, once ever: npm login, then scripts/first-publish.sh from a clean main checkout (it refuses a dirty tree, publishes the same tarballs check-packed-manifests.mjs checked, skips anything already on the registry, asks for the one-time password only if npm asks, and is safe to re-run), then register the six trusted publishers it prints on npmjs.com, then push editor-v0.2.0 and mail-v0.2.0, whose workflow run finds those versions published and goes green. The alternative, an npm token in GitHub secrets so the first publish runs on a runner too, buys only that one script run: the six registrations are a web-UI step either way, and it reverses this repository's deliberate no-token decision. Checked on 2026-09-20: the npm registry answers 404 for every @marlinjai/email-editor* and @marlinjai/mail-* package and a scope search returns nothing, so none has ever been published, while @marlinjai/auth-brain-nextjs is there at 0.8.x, so the scope and the publishing path are real. This line replaces the old one about upgrading ŌPUNTIA's pinned editor past schema 1.1: their live site does not embed the editor at all (it uses the hosted dashboard and only @marlinjai/auth-brain-nextjs), and the single consumer of @marlinjai/email-editor anywhere is an unmerged branch in their repository, feat/studio-mail-phase23, which declares ^0.1.0 and has no lockfile entry because the package does not exist. So it is not an upgrade task: publish first, then that branch can install, and the schema 1.1 upgrade order in the CHANGELOG applies from that point on (2026-09-20)

  • npm: run scripts/first-publish.sh after npm login to publish the packages' first version, register each trusted publisher as the script prints and push the two tags. Done with the 0.3.0 publish above (2026-09-21)

  • Mail service: topics.delete in the contract, the service and the SDK, refused with conflict while a mailing or an automation step uses the topic (the schema's RESTRICT), a subscription or suppression on it going with it, audited as topic.deleted (2026-09-23)

  • Mail dashboard: Marlin enrolls a second factor at auth.lumitra.co for his own sign-in to app.mail.lumitra.co, which requires one (#21 is deployed, auth-brain#142 merged, the GHCR package public). Done before 2026-09-21: the dashboard refuses any session whose sign-in lacks the mfa method (requireAmr in apps/dashboard/src/lib/auth.ts), and Marlin's own session has administered ŌPUNTIA's workspace through it since 2026-09-21, so the factor is enrolled (2026-09-23)

  • Mail service: decide whether to detect bounces that SMTP providers report later by email (iCloud+ reports almost all of them that way), which needs read access to the sender's inbox (IMAP, the Internet Message Access Protocol) and a parser for delivery status notifications; today only the immediate SMTP rejection and Resend's events suppress, and the provider card says so. Recommendation (2026-09-23): not as an inbox reader. Reading a customer's mailbox is a new class of access (a stored IMAP password, every mail in the inbox visible to the service, and a privacy notice that has to say so), for bounces that only one provider kind reports that way. Offer a bounce address instead: the service sets Return-Path to bounces+<token>@mail.lumitra.co on SMTP sends and receives the delivery status notifications itself (one inbound route, the token names the recipient, the parser is the same standard format), so no customer credential is stored and iCloud+ bounces suppress like Resend's events. A day of service work plus a DNS record; decide when a second SMTP workspace complains about a bounce it never saw (2026-09-23)

  • Mail service: decide what user.erased means for workspace members (remove the person's member rows, and what happens to a workspace whose last owner is erased); today the service acknowledges it as a no-op and is subscribed only to tenant.erased. Recommendation (2026-09-23): remove the person's member rows and pending invites, keep the audit log (the entries name a subject id, which auth-brain has already erased, so they no longer identify anybody), and never delete a workspace on a person's erasure: a workspace belongs to a company (company_id, erased with tenant.erased), not to a member. A workspace whose last owner is erased keeps running for its company and shows in the dashboard as "no owner" to any admin of that company, who can take it; the service exposes that state on workspaces.list (owner_count: 0) so the dashboard can say so. Half a day: subscribe to user.erased, one repository method in a transaction, the acknowledgement rules the erasure route already has (2026-09-23)

  • Decide whether the editor's model should grow to hold the constructs an MJML import still keeps as Raw HTML (the mail is unchanged, they are only not editable as blocks): an mj-hero with content, mj-social with MJML's own icons, a hand-written mj-table. Each needs a schema field and an inspector; since the canvas is the compiled email (2026-09-22) there is no renderer to write any more, which halves the cost. Recommendation (2026-09-23): grow the model only for mj-hero with content (a hero with a heading, a paragraph and a button is the one construct a designer reaches for and cannot build from blocks), as a children list of Text and Button blocks on the Hero block; leave mj-social with MJML's icons and hand-written mj-table as Raw, which the canvas now shows exactly as sent, so "not editable as a block" costs nothing visible. Decide when a template with a content hero shows up in a workspace; none has (2026-09-23)

  • Decide whether hosts may add new block types (not only redefine the 14 standard ones): it needs an open block type in the store and schema and a compile hook the server can trust; the canvas renderer hook is no longer needed, since the canvas is the compiled email (2026-09-22). Recommendation (2026-09-23): no. The server compile is what is sent and it runs inside the service, so a host's block would have to ship its compiler to the service (code from a client running in Lumitra Mail) or the block would compile only on the host's side and never match what is sent. The Raw block plus saved sections cover what a host wants from a custom block (its own markup, reused); redefining a standard type's defaults and label stays. Close this line unless a client asks for a block that neither covers, and name the block then (2026-09-23)

  • Mail service billing, test mode, if Marlin still wants a dry run before going live: no Infisical project holds a test key (checked 2026-09-23: Lumitra QR and Ultra Power carry only the live one), so he reads the shared account's test secret key off the Stripe dashboard (test mode on, Developers, API keys) into Infisical "Lumitra Mail" dev as STRIPE_SECRET_KEY (dev still holds PLACEHOLDER_REPLACE_ME). Then one execute_with_secrets call runs apps/service/scripts/stripe-setup.mjs with destinations in dev, exactly as apps/service/README.md "Stripe setup: one command" spells out. Skippable: Marlin asked on 2026-09-23 to reuse the account's existing key, which is the live one (2026-09-23)

  • Mail service billing, live mode: the shared account's live STRIPE_SECRET_KEY is in Infisical "Lumitra Mail" prod since 2026-09-23 (copy_secret from the Ultra Power project, proxy fingerprint 2b28a2b1ec8f, replacing the placeholder; nothing else changed, the price ids are still placeholders so a checkout cannot start). Still Marlin's decision: prices, limits and tax (the mail service plan's S5 defaults 1 and 2, or his own numbers). Then the setup command with --live and destinations in prod only; redeploy; then one real checkout, a plan switch in the portal and a cancellation, refunded (2026-09-23)

  • Lumitra Mail privacy notice (needs Marlin's legal review): written on 2026-09-23 as mail.lumitra.co/privacy (/privacy/en, /privacy/de; apps/service/src/pages/privacy-i18n.ts, the landing page's frame and policy), the processor role, what is stored for recipients and operators, Hetzner, the workspace's provider and Storage Brain on Cloudflare R2, retention, erasure and the rights; the landing footer's privacy link points at it and the notice links lumitra.co/datenschutz for the website. Still open, for Marlin: the legal review of the text (every fact in it is checked against the code, the wording is not lawyered), the Art. 28 processing agreement template (a draft is docs/public/processing-agreement.md). Done since: mail.lumitra.co and app.mail.lumitra.co are on the subdomain list of lumitra.co/impressum (lumitra-web #21), and both texts name the processor as Marlin gave it on 2026-09-23, Marlin Jai Pohl, Grumbkowstr. 6, 13156 Berlin, in the notice's "Who is responsible" section and the agreement's parties (2026-09-23)

Recently shipped

  • Editable wrappers: a container around several sections (MJML mj-wrapper) is added, wrapped, unwrapped, dragged and styled visually, imports from MJML as a container instead of Raw HTML, and exports as mj-wrapper; document schema 1.1, with 1.0 documents migrated (2026-09-19) plan

  • MJML import (paste or upload, preview, warnings, remote images copied on request) and export of templates and mailings as MJML or HTML, in the core, the service, the SDK and the dashboard (2026-09-19) plan

  • Lumitra Mail, the multi-tenant mail service with ŌPUNTIA's Studio as its first client, built S0 to S5 and deployed at https://mail.lumitra.co (API, hosted unsubscribe and signup pages, landing page) and https://app.mail.lumitra.co (dashboard) on 2026-09-18 and 2026-09-19 plan

  • Lumitra Mail landing page live at https://mail.lumitra.co/ in five languages (#31, 2026-09-19); email-editor.lumitra.co answers 308 to it through the redirect Worker, and email.lumitra.co links back (email-mcp #18) plan

  • Depth-2 nested columns (a column can split into 2-4 sub-columns) with a full canvas UX: handle-on-hover, inspector panel, layers-panel nesting, Backspace-to-delete with auto-merge, container-block palette filter. Compiles to email-safe nested HTML for the 85-90% client tier (Apple Mail + Gmail + modern Outlook).

Phase 0 - Foundation (complete)

  • Core editor with drag-and-drop blocks
  • MJML compilation (server-side)
  • MobX State Tree state management
  • 14 of 14 block types with visual renderers
  • 35 prebuilt section templates
  • Undo/redo, device preview, theming
  • Next.js example app
  • Clearify documentation
  • Cloudflare deployment at email-editor.lumitra.co
  • Rename packages from @returnhypnosis/ to @marlinjai/
  • Complete remaining 6 block type renderers (Accordion, Navbar, Carousel, Table, Header, Footer)
  • Add test coverage (181 tests: schema, registry, store, compiler)

Phase 1 - Template Management (complete)

  • Template library dashboard (card grid with thumbnails)
  • Template CRUD (create, duplicate, rename, delete, archive)
  • Template categories and tags
  • Auto-generated template thumbnails
  • Version history per template
  • Import/export templates (JSON + HTML)
  • DatabaseAdapter for template storage (originally shipped against Data Brain, migrated to generic DatabaseAdapter in 2026-03 after Data Brain archive)
  • Storage Brain adapter for image assets

Phase 2 - Contacts & Audiences (complete)

  • Contact list management
  • CSV import
  • Segments and tags
  • Merge fields / personalization tokens ({{first_name}})
  • Unsubscribe management (CAN-SPAM / GDPR)
  • Contact activity history

Phase 3 - Campaign Builder (complete)

  • Campaign creation wizard (template -> audience -> configure -> send)
  • Resend sending adapter
  • Campaign scheduling (now, later, timezone-aware)
  • Send preview / test email
  • A/B testing (subject lines, content variants)
  • Campaign status dashboard

Phase 4 - Analytics (complete)

  • Open/click/bounce tracking
  • Click heatmap on emails
  • Per-contact engagement scoring
  • Campaign comparison reports
  • Export reports

Phase 5 - Teams & Workspaces (complete)

  • Multi-user workspaces with roles
  • Approval workflows
  • Template locking
  • Audit trail
  • Brand kit (locked colors, fonts, logos)

Phase 6 - Automation (complete, then replaced)

  • Trigger-based email sequences
  • Event-based sends
  • Conditional logic in sequences
  • External event integration hooks

The package that carried this, @marlinjai/email-automation, was removed on 2026-09-20. The service owns automations now: durable per-contact runs in the platform worker, the flow document in @marlinjai/mail-contract, and the canvas in the dashboard.

Phase 7 - SaaS Dashboard (complete)

  • Dashboard layout with sidebar navigation
  • Template management route
  • Contact management route
  • Campaign management route
  • Analytics dashboard route
  • Automations dashboard route
  • Workspace settings route
  • Mock data adapters for demo

Future

The former open items here (a live database instead of the mock adapters, user authentication, billing, custom domains, a preview rendering service, webhook management) are now phases of the mail-service plan under "Now" and are tracked there.