Backend integration
Getting startedBackend 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#
- Install the SDK. Run npm install email-craft-sdk on your backend.
- Create one client. Set MAILKARO_SERVER_KEY, then build a single shared client with Mailkaro.fromEnv(process.env).
- 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.
- Allow the editor origin. Set CORS to reflect https://embed.mailkaro.com with credentials, and allow the organization-id header if you use it.
- 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#
npm install email-craft-sdk
# or pnpm add email-craft-sdk / yarn add email-craft-sdkCreate the client#
One memoized instance, built from your environment (see the variables below):
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.
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:
@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, });
}
}| Prop | Type | Default | Description |
|---|---|---|---|
GET /mailkaro/embed-runtime | resolveRuntime() | - | Plan, entitlements, merge tags + capabilities. Required. |
POST /mailkaro/ai | ai(method, input) | - | BYO AI, server-to-server. Powers the AI helper. |
POST /mailkaro/upload | uploadAsset(file) | - | Drag-drop images to your S3. See image uploads. |
GET /mailkaro/s3/list | scopeS3Assets(…) | - | Optional “browse my bucket” tab. See Amazon S3. |
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:
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:
<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}
/>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:
| Prop | Type | Default | Description |
|---|---|---|---|
MAILKARO_SERVER_KEY | required | - | 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_URL | optional | - | Serve uploaded assets from a CDN / custom domain instead of the raw S3 URL. |
MAILKARO_S3_PREFIX | optional | - | 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_URL | rarely set | - | Defaults to MailKaro's hosted API. |
MAILKARO_ORG_ID | leave unset | - | Not needed, the org resolves from the server key (token-only). |
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.