Backend integration

Getting started

Backend integration

Everything paid runs server-side. Add email-craft-sdk to your backend, expose a few small endpoints, and the embedded editor lights up, plan, AI, uploads, white-label, with no secret ever in the browser and no per-feature frontend code.

Set up your backend in five steps#

  1. Install the SDK. Run npm install email-craft-sdk on your backend.
  2. Create one client. Set MAILKARO_SERVER_KEY, then build a single shared client with Mailkaro.fromEnv(process.env).
  3. Add the routes. Expose GET /embed-runtime, which returns resolveRuntime(). Add POST /ai and POST /upload if you want AI and uploads, and GET /s3/list for the optional bucket browser.
  4. Allow the editor origin. Set CORS to reflect https://embed.mailkaro.com with credentials, and allow the organization-id header if you use it.
  5. Point the editor at your runtime URL. Pass your pk_live_ token and runtimeUrl to EmailEditor on the frontend.

Why a backend#

The editor renders in MailKaro's hosted iframe (embed.mailkaro.com) and holds no secret of its own. Your backend resolves the runtime server-to-server with the SECRET server key sk_live_… (MAILKARO_SERVER_KEY), the private half of your token pair; the publishable pk_live_… goes on the frontend. You never send an org id, the org resolves from the server key itself.

Install#

bash
npm install email-craft-sdk
# or  pnpm add email-craft-sdk   /   yarn add email-craft-sdk

Create the client#

One memoized instance, built from your environment (see the variables below):

tsmailkaro.ts
import { Mailkaro } from "email-craft-sdk";

// Reads MAILKARO_SERVER_KEY + optional AI / S3 / branding vars. Reuse this one instance.
export const mailkaro = Mailkaro.fromEnv(process.env);
fromEnv throws if MAILKARO_SERVER_KEY is missing. If the editor is optional in your app, build it defensively (try/catch → null) and expose a requireClient() that returns a clean 503, a missing optional integration must never crash boot.

The endpoints the editor calls#

You give the editor one runtimeUrl (e.g. /mailkaro/embed-runtime); it derives the sibling …/ai and …/upload URLs itself (same origin, last path segment swapped). All four are public, the iframe carries none of your session cookies, optionally scoped by a per-tenant header you choose (see the note below) and gated by the token, never by a login.

tsmailkaro.routes.ts, Express
import { Router } from "express";
import multer from "multer";
import { mailkaro } from "./mailkaro";

export const router = Router();
const upload = multer(); // memory storage → file.buffer

// 1) Runtime: plan + entitlements + capabilities (what the editor is allowed to do). Required.
router.get("/embed-runtime", async (req, res) => {
  res.json(await mailkaro.resolveRuntime({ project: String(req.query.project ?? "") }));
});

// 2) BYO AI, the provider key stays here. Body { method, input }.
router.post("/ai", async (req, res) => {
  try {
    res.json(await mailkaro.ai(req.body?.method, req.body?.input ?? {}));
  } catch (e) {
    res.status(400).json({ error: e instanceof Error ? e.message : "AI request failed" });
  }
});

// 3) Drag-and-drop upload → YOUR S3. Multipart field "file".
router.post("/upload", upload.single("file"), async (req, res) => {
  if (!req.file) return res.status(400).json({ error: "file is required" });
  const orgId = String(req.header("organization-id") ?? "").trim();
  if (!orgId) return res.status(400).json({ error: "organization-id header is required" });
  try {
    // scopeId is REQUIRED (per-tenant isolation). The SDK also enforces an image-type allowlist,
    // a size cap, and sanitizes SVGs; a rejected upload throws and is surfaced as a 400 below.
    res.json(await mailkaro.uploadAsset({
      buffer: req.file.buffer, filename: req.file.originalname, contentType: req.file.mimetype, scopeId: orgId, }));
  } catch (e) {
    res.status(400).json({ error: e instanceof Error ? e.message : "Upload failed" });
  }
});

// 4) OPTIONAL, the "browse my S3" tab. Presign with YOUR credentials.
router.get("/s3/list", async (req, res) => {
  const org = String(req.header("organization-id") ?? "").trim();
  if (!org) return res.json({ items: [] });
  const prefix = mailkaro.s3AssetPrefix(org);
  const objects = await listObjects(prefix);               // your S3 SDK
  res.json(await mailkaro.scopeS3Assets(org, objects, (key) => getSignedUrl(key)));
});

Or, in NestJS, the same four routes as a controller:

tsmailkaro.controller.ts, NestJS
@Controller("mailkaro")
export class MailkaroController {
  constructor(private readonly mailkaro: MailkaroService) {} // .client = Mailkaro.fromEnv(process.env)

  @Get("embed-runtime")
  runtime(@Query("project") project?: string) {
    return this.mailkaro.client.resolveRuntime({ project });
  }

  @Post("ai")
  ai(@Body() body: { method: string; input?: Record<string, unknown> }) {
    return this.mailkaro.client.ai(body.method, body.input ?? {});
  }

  @Post("upload")
  @UseInterceptors(FileInterceptor("file"))
  upload(@UploadedFile() file: any, @Headers("organization-id") org?: string) {
    const scopeId = org?.trim();
    if (!scopeId) throw new BadRequestException("organization-id header is required");
    return this.mailkaro.client.uploadAsset({
      buffer: file.buffer, filename: file.originalname, contentType: file.mimetype, scopeId, });
  }
}
PropTypeDefaultDescription
GET /mailkaro/embed-runtimeresolveRuntime()-Plan, entitlements, merge tags + capabilities. Required.
POST /mailkaro/aiai(method, input)-BYO AI, server-to-server. Powers the AI helper.
POST /mailkaro/uploaduploadAsset(file)-Drag-drop images to your S3. See image uploads.
GET /mailkaro/s3/listscopeS3Assets(…)-Optional “browse my bucket” tab. See Amazon S3.
The tenant header is yours. organization-id above is just an example name for a per-tenant scope you send on the upload / S3 routes so one tenant's assets never mix with another's, call it whatever you like and read it the same way in your routes. Building a single-tenant app? Omit it entirely and drop scopeId / connectorHeaders.

CORS#

The iframe fetches with credentials: "include", so reflect the exact origin (never *) and allow the organization-id header:

tscors.ts
app.use(cors({
  origin: "https://embed.mailkaro.com", // the hosted editor's origin
  credentials: true, allowedHeaders: ["Content-Type", "organization-id"],
}));

Wire the editor to your backend#

On the frontend, point the editor at your runtime URL, that's all it needs:

tsxComposer.tsx
<EmailEditor
  token={pk}                                        // pk_live_… (publishable)
  runtimeUrl={`${API}/mailkaro/embed-runtime`}      // your backend
  connectorHeaders={{ "organization-id": orgId }}   // optional: your per-tenant scope header
  mergeTags={mergeTags}
/>
The editor turns on AI, uploads, and white-label automatically from whatever the runtime advertises in capabilities, no per-feature frontend code. For local dev only, set embedUrl to a locally-run editor host; in production leave it unset (defaults to the hosted editor).

Environment variables#

Mailkaro.fromEnv(process.env) reads:

PropTypeDefaultDescription
MAILKARO_SERVER_KEYrequired-Your secret server key sk_live_…. The whole integration hangs off this.
OPEN_AI_KEY / MAILKARO_AI_KEY / CLAUDE_AI_KEY→ AI-Your provider key (BYO AI). Any one enables the AI capability.
MAILKARO_S3_BUCKET + MAILKARO_S3_REGION→ upload-Your S3 bucket + region for drag-drop uploads (standard AWS credential chain).
MAILKARO_S3_PUBLIC_BASE_URLoptional-Serve uploaded assets from a CDN / custom domain instead of the raw S3 URL.
MAILKARO_S3_PREFIXoptional-Key prefix for editor assets (defaults to mailkaro/).
MAILKARO_LOGO_URL / MAILKARO_LOGO_ALT→ white-label-Show your own logo in the editor instead of the MailKaro badge.
MAILKARO_API_URLrarely set-Defaults to MailKaro's hosted API.
MAILKARO_ORG_IDleave unset-Not needed, the org resolves from the server key (token-only).
What you DON'T need: no org id, no frontend AI or upload code, no S3 bucket CORS (uploads proxy through your backend), and no secret in the browser, just the server key, this small endpoint set, and a runtimeUrl on the editor.

Next: Embed tokens · AI · Amazon S3 · Security.

Frequently asked questions#

Which key goes where?
The secret server key (sk_live_) stays on your backend, in MAILKARO_SERVER_KEY. The publishable key (pk_live_) goes on the frontend. A secret key never reaches the browser.
Which environment variable is required?
Only MAILKARO_SERVER_KEY. Mailkaro.fromEnv throws if it is missing. The AI, S3 and white-label variables are optional and switch those capabilities on.
Do I need to send an organization id?
No. The organization resolves from the server key itself.