Skip to content
Back to Vibe coding

AI app builder

Updated

Add email to your Bolt app with Lumail

Bolt can scaffold anything from a Vite single-page app to a full-stack framework. Email needs a server, so the prompt has to say where the send runs, and the key has to stay out of anything prefixed `VITE_`.

TL;DR

Paste the prompt below into Bolt. If the project already has a server (Next.js, Remix, Astro, Express), it adds a server route; if it is a Vite SPA, it adds a serverless function on your host. The function reads LUMAIL_API_KEY from .env, sends a contact-form message to your own inbox with reply_to set to the visitor, and uses an idempotency key.

1. Prompt Bolt

The example is a contact form, because it is the first email most Bolt apps need and it only ever emails you. Swap the first task at the end of the prompt for a welcome email once you have auth.

Prompt for Bolt
Add transactional email to this app with Lumail (https://lumail.io). Stack: the stack already used in this Bolt project. If it has a server runtime (Next.js, Remix, Astro, SvelteKit, Express) add a server route; if it is a front-end-only Vite app, add a serverless function for the host I deploy to and tell me which one you chose. Rules: - Send email only from server-side code. Never call Lumail from the browser and never expose the API key to client code. - Read the API key from the LUMAIL_API_KEY environment variable. Add it to .env without a VITE_ prefix and tell me to set the same variable on my host. Throw a clear error if it is missing. - In TypeScript/JavaScript use the official `lumail` npm package: `new Lumail({ apiKey })`, then `lumail.emails.send({ from, to, subject, html })`. - Elsewhere call the REST API: POST https://lumail.io/api/v2/emails with `Authorization: Bearer <LUMAIL_API_KEY>` and a JSON body. - Pass exactly one body format: `html`, `markdown` or `tiptap`. Add `text` as a plain-text fallback when sending html. - `to` is a single recipient. Loop or use `lumail.emails.batch` (max 100) for several people. - The SDK returns `{ data, error }` and never throws on HTTP errors. Check `error` and log `error.name` and `error.message`. - Pass an `idempotencyKey` (header `Idempotency-Key` over REST) built from the event that triggered the email, such as `welcome:<userId>`. - `from` must use a domain verified in my Lumail organization. Put it in a LUMAIL_FROM environment variable, for example `Acme <[email protected]>`. First task: a contact form. Send each submission to the address in CONTACT_TO with reply_to set to the visitor's email, validate the input server-side, and strip HTML from it. Show me which files you changed and how to test it.

2. Put the API key in the right place

Create a lum_ token in Lumail under API tokens and add it to the project's .env. Anything starting with VITE_ is bundled into the browser code, so the Lumail key must never use that prefix.

When you deploy, set LUMAIL_API_KEY, LUMAIL_FROM and CONTACT_TO in your host's environment variables too. A deployed app does not read your local .env.

.env
# Server-side only. Never prefix with VITE_ or NEXT_PUBLIC_. LUMAIL_API_KEY=lum_your_api_token LUMAIL_FROM="Acme <[email protected]>" [email protected]

3. The code Bolt should generate

Here is the route for a project with a Node server (Express shown). In a framework, the same body goes in its server route or action; on a static host, in its function format. The shape stays the same: validate, send with reply_to, check error.

server/contact.ts
import { Router } from "express"; import { Lumail } from "lumail"; const apiKey = process.env.LUMAIL_API_KEY; if (!apiKey) throw new Error("LUMAIL_API_KEY is not set"); const lumail = new Lumail({ apiKey }); export const contact = Router(); contact.post("/api/contact", async (req, res) => { const { email, message, submissionId } = req.body ?? {}; if (typeof email !== "string" || !email.includes("@") || typeof message !== "string" || !message.trim()) { return res.status(400).json({ error: "Email and message are required" }); } const { error } = await lumail.emails.send( { from: process.env.LUMAIL_FROM ?? "Acme <[email protected]>", to: process.env.CONTACT_TO ?? "[email protected]", reply_to: email, subject: "New contact form message", text: message.slice(0, 5000), html: `<p>From: ${email.replace(/[<>&"]/g, "")}</p><p>${message.replace(/[<>&"]/g, "").slice(0, 5000)}</p>`, }, typeof submissionId === "string" ? { idempotencyKey: `contact:${submissionId}` } : undefined, ); if (error) return res.status(502).json({ error: error.message }); return res.json({ ok: true }); });

4. Lumail MCP server and agent plugins

Bolt builds and hosts your app in the cloud, so your app talks to Lumail through the API with a token, not through MCP. The Lumail MCP server is for agents that run where you work: Claude Code, Codex, ChatGPT, Cursor or the Lumail CLI.

That split is useful once the app is live. Ask Claude Code or ChatGPT to check your domain's DNS status, look up a subscriber who did not get their email, or draft the next newsletter, while the app keeps sending through the API.

5. Verify your sending domain

Lumail only sends from a domain you have verified. Add the domain in your organization's Domains settings, then publish the SPF, DKIM and DMARC records it shows at your DNS provider. Until the domain verifies, every send fails with an error saying the domain is not authorized or verified.

Use a subdomain such as mail.yourdomain.com if your root domain already sends from another provider. Start DMARC at p=none, then tighten it once reports look clean.

Common pitfalls

  • `VITE_LUMAIL_API_KEY`. If the AI adds the prefix to make the key "work" in a component, the key is now public. Rotate it and move the call to a server route.
  • Works in preview, fails in production. The deployed app needs its own environment variables. Set them on the host and redeploy.
  • Unescaped HTML. Form input dropped straight into html lets visitors inject markup into your inbox. Strip or escape it, or send text only.
  • Client-side sends. If the AI imports lumail in a React component or uses fetch to the Lumail API from the browser, the key ships to every visitor. Move it to a server route or function and rotate the token.
  • Unverified `from` domain. Sends from a domain that is not verified in the same organization are rejected with a 400 that names the domain. Verify it first, or use the exact address Lumail shows you.
  • Treating `{ error }` as an exception. The SDK never throws on HTTP errors. Code that only wraps the call in try/catch silently drops failures.
  • Duplicate emails on retry. Without an idempotency key, a retried request or double-clicked button can send twice.

Frequently asked questions

Can a Bolt app send email from the front end?

No. A key in front-end code, including any VITE_ variable, is readable by every visitor. Send from a server route or a serverless function.

My Bolt project has no server. What should I do?

Ask Bolt to add a serverless function for the host you deploy to, or a Supabase Edge Function if the project uses Supabase. The function reads LUMAIL_API_KEY and calls the Lumail SDK or REST API.

Why send contact form messages to myself instead of the visitor?

Because a public endpoint that emails any address it is given can be abused to send spam from your domain. Sending to your own CONTACT_TO address with reply_to set to the visitor is safe and lets you answer from your inbox.

How much does it cost to send these emails?

The Free plan includes 3,000 emails a month. Premium is $20 a month for 40,000 emails, then $0.60 per 1,000. Subscribers are unlimited on every plan.

Can I use the Lumail MCP server with Bolt?

Bolt runs in the cloud, so connect the MCP server to a local agent such as Claude Code, Codex, ChatGPT or Cursor instead. The Bolt app keeps sending through the API.

Keep building

Ship email from your Bolt app today.

3,000 emails a month free. Transactional and marketing email on one verified domain, with unlimited subscribers on every plan.