@marlinjai/mail-react
v0.25.0
Published
Lumitra Mail inside your own app: a server-side bridge that keeps the workspace API key out of the browser, and React views over it
Maintainers
Readme
@marlinjai/mail-react
Lumitra Mail inside your own app: a server-side bridge that keeps the workspace API key out of the browser, and React views over it: the workspace's templates, and the editor with the same save-and-conflict rule the hosted dashboard uses, so both can edit the same template.
The package is the answer to "our staff should see and edit our mail templates inside our own admin, not in a second product". The service stays the single source of truth; your app is one more user interface over it, saving under the same rules the hosted dashboard saves under.
Install
pnpm add @marlinjai/mail-react @marlinjai/mail-sdkHow it fits together
browser your server Lumitra Mail
views ── transport ──POST──▶ bridge ──(key, validated)──▶ https://mail.lumitra.co/v1/...- The bridge (
@marlinjai/mail-react/server) is a handler over the web standardRequestandResponse. It takes an operation id and its input, checks the operation against the allowlist you gave it, validates the input against the contract's own schemas, and forwards it through a@marlinjai/mail-sdkclient that holds the workspace API key. No browser header is ever passed through, and the service's address never appears in a response. - The transport (
@marlinjai/mail-react) is what runs in the browser: it posts to the bridge and gives back the service's typed response, or throws aMailBridgeErrorcarrying the service's error code, or the bridge's own.
Mount the bridge (Next.js)
// app/api/mail/bridge/route.ts
import { createMailClient } from '@marlinjai/mail-sdk';
import { createMailBridge, READ_OPERATIONS } from '@marlinjai/mail-react/server';
import { currentAdmin } from '@/lib/auth';
const client = createMailClient({
baseUrl: process.env.MAIL_SERVICE_URL!,
apiKey: process.env.MAIL_SERVICE_API_KEY!, // a `send` key covers editing; `full` is only for admin routes
});
const bridge = createMailBridge(client, {
// Only what the pages behind this route need. A read-only page mounts READ_OPERATIONS alone.
allow: [...READ_OPERATIONS, 'templates.create', 'templates.update', 'assets.upload'],
// Who your app is acting for: recorded on the audit log as reported by your application.
onBehalfOf: async (request) => (await currentAdmin(request))?.email,
});
export const POST = (request: Request) => bridge(request);Put your own session check in front of it: the bridge trusts whoever reaches
it, exactly as any other route of your admin does. What it does on its own is
refuse a request the browser made for another site: 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 (the bridge answers no
preflight), and a request the browser labels sec-fetch-site: cross-site is
refused outright. The transport sends the header on every request; a
transport of your own must too.
The allowlist is checked when the bridge is built, not at the first click: an
operation a key can never satisfy (workspace.move, creating workspaces) or a
file export throws at mount with a message that says why.
allow says which operations a page may reach; refuse says which calls of
them it may not. It sees each call after the allowlist and the input's
validation (the operation, its path parameters, query and body) and returns a
reason to refuse it, which the view shows, or nothing to forward it. It can
only refuse. A view's restraint is feedback, never enforcement: a caller can
post to the bridge without the view, so a rule the host must keep goes here.
Use the transport
import { createBridgeTransport } from '@marlinjai/mail-react';
const mail = createBridgeTransport({ url: '/api/mail/bridge' });
const page = await mail.call('templates.list', { query: { limit: 20 } });
const template = await mail.call('templates.get', { params: { id } });
await mail.call('templates.update', { params: { id }, body: { base_version: template.version, name: 'Renamed' } });
const asset = await mail.upload('assets.upload', { file, filename: file.name });A mutating call gets an idempotency key minted on the browser side and reused across a retry, so a retried save applies once.
The views
import '@marlinjai/mail-react/styles.css';
import '@marlinjai/email-editor/styles.css';
import { createBridgeTransport } from '@marlinjai/mail-react';
import { MailProvider, TemplateList, TemplateEditor } from '@marlinjai/mail-react/views';
const transport = createBridgeTransport({ url: '/api/mail/bridge' });
export function MailTemplates({ openId, onOpen, onBack }) {
return (
<MailProvider transport={transport} theme={{ colors: { primary: '#b8912f' } }} placeholderBase="https://mail.lumitra.co">
{openId ? <TemplateEditor templateId={openId} onBack={onBack} /> : <TemplateList onOpen={(t) => onOpen(t.id)} />}
</MailProvider>
);
}And on the server, the bridge allows what the views need:
import {
TEMPLATE_LIST_OPERATIONS,
TEMPLATE_LIST_ARCHIVE_OPERATIONS,
TEMPLATE_LIST_DELETE_OPERATIONS,
TEMPLATE_LIST_RENAME_OPERATIONS,
TEMPLATE_EDITOR_OPERATIONS,
} from '@marlinjai/mail-react/server';
createMailBridge(client, { allow: [...TEMPLATE_LIST_OPERATIONS, ...TEMPLATE_EDITOR_OPERATIONS] });TemplateListshows every template live from the service (name, version, when it changed, the last mailing that used it and how many it sent), with archived ones behind a toggle, each template's languages ("EN main", "FR", "DE draft"), a preview of the saved version (in any of its languages) compiled on request into a sandboxed frame, and an Open action that hands you the row. Two opt-in actions retire templates:canArchive(Archive on live rows, Unarchive on archived ones) andcanDelete(Delete on archived rows only, confirmed by typing the template's name; the template and its history go, mailings made from it keep their own copy). The delete carries the version that was confirmed, so a template somebody unarchived or renamed while the dialog was open is not deleted: the conflict is shown and the list reloaded. Each action needs its own operations in the bridge'sallow, which the plain list does not include:TEMPLATE_LIST_ARCHIVE_OPERATIONS(templates.update) forcanArchive,TEMPLATE_LIST_DELETE_OPERATIONS(templates.delete) forcanDelete, so a host that only archives never forwards a delete. Passlockedto say which templates must stay (one your own code sends, the base the others are copied from): their actions are disabled with your reason. That is feedback in the list; give the bridge'srefusethe same rule, so the templates stay whoever calls the bridge:const KEPT = new Set([ORDER_CONFIRMATION_TEMPLATE_ID]); const lockedReason = (id: string) => (KEPT.has(id) ? 'The shop sends this template, so it stays.' : null); // On the server: createMailBridge(client, { allow: [...TEMPLATE_LIST_OPERATIONS, ...TEMPLATE_LIST_ARCHIVE_OPERATIONS, ...TEMPLATE_LIST_DELETE_OPERATIONS], refuse: ({ operation, params, body }) => operation === 'templates.delete' || (operation === 'templates.update' && body?.archived !== undefined) ? lockedReason(String(params?.id)) : null, }); // In the page: <TemplateList canArchive canDelete locked={(t) => lockedReason(t.id)} />;canRenameedits a template's name and description in a dialog, saved on the row's version: a change made meanwhile is shown as a conflict and the list reloaded, never overwritten. It needsTEMPLATE_LIST_RENAME_OPERATIONS(templates.update) in the bridge'sallow, andlockeddoes not hold it back, since a new name changes nothing that is sent. The Name, Version and Updated headers sort, by the service, so the order holds over every page. Every column can be resized by dragging a header's edge or with the arrow keys on it (a double-click resets it); the widths are kept for the viewer inlocalStorageunderstorageKey(mail-react:template-listby default,nullto keep none) and never sent.TemplateEditorloads one template into@marlinjai/email-editor(a peer dependency, loaded after mount so a server-rendered page can import the view), with the workspace's saved sections in the picker, "Save as a section", a preview of the unsaved document, and an image dialog that uploads to the workspace's assets unless you pass your ownonRequestImage. A save carries the version that was loaded; when somebody saved in between, the person decides (load theirs, or save mine over it), never a silent overwrite.onSavedandonConflicttell you what happened. A template in several languages gets a tab per workspace language: add a version (a draft copy of the main language), give it its own subject and preheader, mark it ready, set it back to draft or remove it; a save sends only the languages that changed. Insertions and the data the template receives are edited through the insertions surface below, inside the editor: the canvas shows each one as its text, the Insertions tab lists them with what each language lacks, the Inspector edits the selected one, and the example values sit in the editor's toolbar. The subject and preheader each have an Insert menu (a merge field or an insertion at the caret, or a new insertion) and a "Reads as:" line for the example values. Renaming an insertion renames every place that prints it, in the body, the subject and the preheader of every language, in one step (the Inspector says where, and offers "Undo rename"). A language is marked ready only with every text, and a save that sets ready languages back to draft names them and asks first. The Preview pane renders the open language with the same example values. PassinputsGuideUrlto link a page on declaring a template's inputs from the Insertions tab when a template has none.Unsaved work between page loads. Pass
onDraftChangeto keep what was typed in your own storage, anddraftto restore it. A restored draft is saved on the version it was made on, so a template that moved on becomes the usual conflict decision. It carries every language's unsaved work, the insertions included when they were edited. Insertions are restored only onto the version they were edited against: when the template's insertions moved on since, the current ones are kept (in every language) and the restore notice says so, so an old draft never hides or deletes them. A language only the draft has comes back without its texts, and as a draft when it was ready.ContactList,ContactDetailandSentArchiveare read only: the workspace's contacts (found by the route's own filters, an exact email address, an external id or a topic), one contact with the blocks on its address and why, the messages it received and the automations it is in, and every message the workspace sent, each openable as it went out in a sandboxed frame. Changing a contact, lifting a block, importing or sending stays in the hosted dashboard, and the empty states say so. Each view has its*_OPERATIONSconstant for the bridge's allowlist.The insertions surface (
useInsertionsSurface) is the one implementation of insertions and template inputs for any template editor built on@marlinjai/email-editor. Give it the template's language drafts, the open language and the template'sinputsandinputs_seen, with the id of the template those drafts belong to (loadedTemplateId, null while they load, so a template switch never keeps one template's example values under another's); pass what it returns to the editor (tokens,extraTabs,toolbarItems) and render itsoverlayonce beside it:const surface = useInsertionsSurface({ templateId, loadedTemplateId: template?.id ?? null, drafts, onChange: setDrafts, locale, mainLocale, onLocaleChange: setLocale, inputs: template?.inputs, inputsSeen: template?.inputs_seen }); <EmailEditorReact tokens={surface.tokens} extraTabs={surface.extraTabs} toolbarItems={surface.toolbarItems} ... /> {surface.overlay}The canvas then shows every insertion and merge value as its text for the author's example values and the open language ("Dear Anna,"; an empty one as "nothing here, this line is left out"; one not written yet as "Missing in German"). A click on it opens the Inspector with plain rows named by the declared kind of the value (Yes and No; Has a value and Is empty, or Has a name and No name for
first_nameandlast_name; a row per declared value with its label, then Anything else and Not given; without a declaration a row per value, Any other value and When it is empty). The stored insertion is unchanged: the rows are its choices,filledandotherwise. The Insertions tab lists every insertion with what each language lacks, and the data the email receives as the app declared it, with what real sends carried. The example values (a switch and a popover in the toolbar) are kept per template in the browser, never saved or sent. On a language other than the main one only that language's texts are edited. The pieces are exported too (InsertionInspector,InputInspector,InsertionsTab,ExampleValues,DependsOnPicker,NewInsertionDialog,useTemplateTokens,useExampleValues), with the pure model under them (insertionRows,exampleControls,readToken, andexamplePersonalizationfor a host's own preview with the example values).A host that composes a mailing rather than a template has real readers, and can show one of them in place of the example values: pass
supplied, one recipient of the mail service's dry run (mail.mailings.resolve), touseInsertionsSurfaceoruseTemplateTokens, or put it on theReadingContexthanded toreadTokenandexamplePersonalization.const dryRun = await mail.mailings.resolve(mailingId, { recipients }); const surface = useInsertionsSurface({ ...options, supplied: dryRun.recipients[0] });The canvas then shows each value as that reader gets it (the mailing's own values, the contact, the workspace's defaults and the fallback already applied by the service) and each insertion's text for the choice the dry run made.
toolbarItemsis null, the Inspector marks the row the reader gets and offers no example to set, and the author's kept examples are neither read nor changed. Without recipients the dry run answers for a reader with nothing of their own, which shows every fallback and every line that would be left out.Styling. The views read the editor's design tokens (
--ee-*), so onethemeon the provider covers both. The stylesheet loads nothing from anywhere.
Errors
Every failure is a MailBridgeError with code (the contract's error codes),
status, details and message. fromBridge says whether the bridge
refused (an operation outside the allowlist, invalid input, the service
unreachable) or the service did (a conflict on a stale save, forbidden
for a key scope, not_found). For validation_failed, issues lists the
failing paths.
The on-behalf-of label
A key is shared by everybody who uses your app. Without more, every edit made
through it is one key in the audit log. onBehalfOf sends a label (a name or
an email address, at most 200 characters) that the service records on the
audit entry as on_behalf_of, shown in the dashboard as reported by the
client's application. It is a label, never a credential: it is not verified
and it changes nothing about what the key may do. If you want verified
per-person attribution, create one key per person instead.
The handoff: everything else, in the hosted dashboard
Members, keys, providers, billing, automations, sending and imports are not
meant to go through a bridge; the hosted dashboard at app.mail.lumitra.co
does those, and the person signs in there as a member of the workspace. The
link is a plain link (nothing here mints a token or carries a credential):
import { HandoffButton } from '@marlinjai/mail-react/views';
<HandoffButton workspaceId={workspaceId} kind="automations" />
<HandoffButton workspaceId={workspaceId} kind="template" id={template.id}>Open in Lumitra Mail</HandoffButton>mailHandoffUrl({ workspaceId, kind, id? }) is the same as a string, exported
from every entry, and HANDOFF_KINDS lists what a link can point at
(automations, a mailing, a template's history, imports, members and so on).
A kind that needs an id and was not given one throws where the link is
written, not at the click.
Knowing when a template changed
A host that reads templates live through the views never needs more. A host
that caches or mirrors them subscribes its webhook endpoint to
template.created, template.updated and template.deleted: each carries
the template's id, name, the version after the change and which user
interface made it (api for a key, dashboard for a member, mjml_import),
never the document, which the service holds.
