Embedding Lumitra Mail in your own app
Your staff should see and edit your mail templates inside your own admin, not in a second product; the people you write to should stay in your system of record; and the parts that are a product in their own right (sending queues, unsubscribe pages, automations, billing) should not be rebuilt in every app. That is what the two packages are for:
@marlinjai/mail-sdkis a typed client over every route of the service. Your backend holds a workspace API key and calls whatever it needs.@marlinjai/mail-reactis the browser side: a bridge you mount on your server so the key never reaches a browser, React views over it (templates, the editor, contacts, the sent archive), and a link into the hosted dashboard for everything else.
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, so both can edit the same template without either overwriting the other silently.
The floor: the SDK on your server
import { createMailClient } from '@marlinjai/mail-sdk';
const mail = createMailClient({ baseUrl: process.env.MAIL_SERVICE_URL!, apiKey: process.env.MAIL_SERVICE_API_KEY! });
const templates = await mail.templates.list({ limit: 50 });A key is scoped to one workspace and to full, send or read. Editing
templates, uploading images and sending need write, which send covers;
full is only for the admin routes (providers, members, keys). Keep the key
in your secret store, and never run the SDK in a browser with a workspace
key: that is what the bridge below is for.
When one key is shared by everybody who uses your app, every write through it
is audited as that key. onBehalfOf on a call (or on the bridge, below) adds
a label naming the person your app acts for, which the service records on the
audit entry as reported by your application, never verified, and never a
change to what the key may do. A client that wants verified per-person
attribution creates one key per person instead.
The bridge: the key stays on the server
The views in the browser never talk to the service. They post an operation id and its input to a bridge on your server, which checks the operation against the allowlist you gave it, validates the input against the contract's own schemas, forwards it through the SDK with the key, and answers with the service's response or its error envelope.
// app/api/mail/bridge/route.ts (Next.js; Hono or Express work the same way)
import { createMailClient } from '@marlinjai/mail-sdk';
import { createMailBridge, TEMPLATE_LIST_OPERATIONS, TEMPLATE_EDITOR_OPERATIONS, SENT_ARCHIVE_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! });
const bridge = createMailBridge(client, {
allow: [...TEMPLATE_LIST_OPERATIONS, ...TEMPLATE_EDITOR_OPERATIONS, ...SENT_ARCHIVE_OPERATIONS],
onBehalfOf: async (request) => (await currentAdmin(request))?.email,
});
export async function POST(request: Request) {
if (!(await currentAdmin(request))) return new Response(null, { status: 401 });
return bridge(request);
}Three rules the bridge enforces on its own, and one it leaves to you:
- Only what the allowlist names. Each view exports the operations it
needs (
TEMPLATE_LIST_OPERATIONS,TEMPLATE_EDITOR_OPERATIONS,CONTACT_LIST_OPERATIONS,CONTACT_DETAIL_OPERATIONS,SENT_ARCHIVE_OPERATIONS), so yourallowis a union of those. A page that only reads mountsREAD_OPERATIONSalone. An operation a key can never satisfy (creating workspaces, moving one) or a file export is refused when the bridge is built, with a message that says why. - Validated before anything reaches the service. Params, query and body
are checked against the same zod schemas the service uses; a bad input is
validation_failedwith the failing paths, and the service never sees it. - Not from another site. Every request must carry the
x-mail-bridgeheader, which an HTML form on another origin cannot set and which makes a cross-origin fetch preflight (the bridge answers no preflight); a request the browser labelssec-fetch-site: cross-siteis refused. The transport sends the header on every request. - Your session check is yours. The bridge trusts whoever reaches it, as any route of your admin does. Put your own check in front, as above.
The views
import '@marlinjai/mail-react/styles.css';
import '@marlinjai/email-editor/styles.css';
import { createBridgeTransport } from '@marlinjai/mail-react';
import { MailProvider, TemplateList, TemplateEditor, ContactList, ContactDetail, SentArchive, HandoffButton } from '@marlinjai/mail-react/views';
const transport = createBridgeTransport({ url: '/api/mail/bridge' });
<MailProvider transport={transport} theme={{ colors: { primary: '#b8912f' } }} placeholderBase="https://mail.lumitra.co">
<TemplateList onOpen={(t) => router.push(`/admin/mail/templates/${t.id}`)} />
</MailProvider>TemplateList: every template live from the service, its version, when it changed, the last mailing that used it and how many it sent, archived ones behind a toggle, and a preview of the saved version compiled on request into a sandboxed frame.- Full screen. The editor's toolbar carries a full-screen button: the editor's root covers the viewport (position fixed, above the host's chrome) until the button or Escape ends it. Nothing to wire; a host that boxes the editor into a short region gets the whole screen on demand.
- The editor's canvas is the compiled email.
@marlinjai/email-editorcompiles the document in the browser withmjml-browser(a chunk of about 350 KB gzipped that loads when the editor mounts, never before) and shows it in an<iframe sandbox="allow-same-origin">filled throughsrcdoc, which runs no scripts. Such a frame inherits the host page's Content Security Policy (CSP), and a compiled mail is inline styles and<style>blocks through and through, so a host whosestyle-srclacks'unsafe-inline'gets an unstyled canvas, andimg-srcmust allow the hosts the mail's images come from (the service's own origin for uploaded assets and placeholders). No script and no other network resource is loaded. The browser compile is display only; what is sent is the service's own compile, andTemplateEditorasks the service a few seconds after each edit what it refuses, which the editor shows as a badge in its toolbar. TemplateEditor: one template in@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 that says when it went stale, 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, save mine over it, or decide later.onSavedandonConflicttell you what happened. In a workspace with more than one language, a tab per language sits over the canvas (the main language first, then each workspace language as ready, draft or missing, then versions kept for a language the workspace no longer lists, which can only be removed). "Add" starts a version as a draft copy of the main language, each tab has its own subject and preheader, "Mark ready" (which needs a subject) decides whether readers of that language get it, and a save sends only the languages that changed. The languages come fromworkspace.get; a bridge that does not forward it gets the main language only, with a note saying why.ContactList,ContactDetail,SentArchive: read only. The contacts with the route's own filters (an exact email address, an external id, 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. Changing a contact, lifting a block, importing or sending stays in the hosted dashboard.HandoffButton: a plain link into the hosted dashboard for everything else (automations first, then mailings, a template's history, imports, members, keys, providers, billing). The person signs in there as a member of the workspace; nothing mints a token or carries a credential.
The views read the editor's design tokens (--ee-*), so one theme on the
provider covers the editor and the views, and their stylesheet loads nothing
from anywhere.
Unsaved work between page loads
TemplateEditor hands you a draft after every settled change
(onDraftChange), and restores one you pass back (draft). A draft carries
the version it was made on, so a template that moved on in the meantime
becomes the usual conflict decision at the next save rather than a silent
overwrite. A draft of a template in several languages also carries the
language versions added or edited (translations) and removed (removed),
so every language's unsaved work comes back. Keep drafts in your own storage,
never in the service.
Knowing when a template changed
A host that reads templates live 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. Verify and parse them as any other event
(docs/public/mail-contract.md, Webhooks).
What to test in your app
The edit loop is a stateful flow, so test the four paths, as the package's own tests do:
- Forward: load, edit, save; the service holds the new version.
- Backtrack and revise: an edit after a preview invalidates that preview; the same document keeps it.
- Resume from persistence: a reload mid-edit restores the draft and the version it was made on; a save then still carries the right version.
- Re-entry after a conflict: a save on a stale version shows the decision; "load theirs" discards yours; "save mine" saves on the current version, and both saves are in the audit log.
Transactional sends from your backend
A one-to-one letter is mailings.create with a template_id or a document
and no topic, one recipient through mailings.addRecipients, and
mailings.send, all write. Its {{unsubscribe_url}} opens the hosted page
with "unsubscribe from everything" first, and using it blocks the address on
every topic (contact.unsubscribed with topic: null), so the same footer
serves broadcasts and letters. A letter holds one recipient, is stopped only by
a block on every topic, and carries the same one-click List-Unsubscribe
headers as a broadcast, pointing at its all-topics page; the contract
documentation's "Letters" section has the rules. Your client needs contract
0.4.0 or later (where Mailing.topic may be null) before it creates one.