A guide for teams whose emails live in code: inventory them with a coding agent, put them on one Brew canvas over MCP, then migrate and improve them safely.

Emails in code are product emails defined in your codebase and shipped like any other feature. On a team built on Resend and React Email, ask which emails the product sends and you get a pause. The welcome email is a component in emails/. Password reset lives in the auth provider's dashboard. The trial-ending email is a string in a cron job. Stripe sends the receipt. Nobody remembers what sends the "we miss you" email.
Each email was easy to ship, so nobody owns the full picture. We'd fix that in order: visibility, then migration, then improvement. The coding agent you already use can do most of it, connected to Brew over MCP.
import_email MCP tool. Import is deterministic, uses no AI and is free.React Email and Resend made email far better for developers: typed components, reviewed in pull requests. The cost shows up later.
The emails are scattered. Components, inline HTML in a background job, templates in a third-party dashboard, emails another service sends for you. No file lists them all.
Previews are not production. The React Email preview server shows one template with its preview props, not what a customer got, and nothing for other templates.
Only engineers can look. Product, design and support would have to ask an engineer to run the project, so mostly they don't.
Nothing gets reviewed after launch. The two-year-old email still has the old logo and a plan you no longer sell. Nobody notices because nobody sees them together.
The goal is one shared view of every email. Nothing here touches production.

Each phase is useful on its own, and you can stop after any of them.
Brew's MCP server is at https://brew.new/api/mcp and uses OAuth: you sign in with Brew and choose the brand the agent can work in. In Claude Code:
claude mcp add --transport http brew "https://brew.new/api/mcp"The client setup docs have entries for Cursor and Codex. Clients without OAuth can use a brand-scoped API key. Then have the agent call get_brew_capabilities so it reads the live tool list instead of guessing.
Ask the agent for a list:
Find every email this codebase sends. Search for provider calls
(resend.emails.send, postmark, sendgrid, nodemailer, SES), React Email
components and render() calls, and any email templates referenced from
config or a third-party dashboard.
For each email, record: a name, what triggers it, the file and line that
sends it, the template file, the provider, and the variables it uses.
Write the result as a table in EMAILS.md. List anything you could not
resolve under "Unknown" instead of guessing.Keep the "Unknown" section. Auth confirmations and Stripe receipts aren't send calls in your code, but they belong on the list. Commit EMAILS.md. If you stop here, you still have a list you didn't have yesterday.
The best import input is the HTML your code already produces. React Email's render utility gives you that string:
import { render } from 'react-email'
import WelcomeEmail from './emails/welcome'
const html = await render(
<WelcomeEmail firstName="Ana" planName="Pro" trialDays={14} />
)Older projects import render from @react-email/render. Use realistic fixtures, not "test", since your team will review these. Have the agent script a render of every template to a file.
Brew also imports a React Email module directly with format: "jsx", reading it statically without executing it. Props resolve to defaults or PreviewProps, components imported from other local files are dropped or rejected, and await and similar are refused. Beyond one self-contained file, use rendered HTML.
Then import each render with import_email, one groupName per product area:
For each rendered email in ./email-renders, call import_email with
format "html", the title and subject line from EMAILS.md, and a
groupName by area: "Auth", "Onboarding", "Billing" or "Lifecycle".
Report the emailId for each one and any warnings in its assetReport.Developers rightly ask whether import rewrites their emails. It doesn't:
assetReport lists any it couldn't fetch.baseUrl only resolves relative image paths.Each group gets a canvas. Open brew.new/emails/onboarding and the whole sequence sits side by side, with a desktop and mobile toggle and comments on each design. Canvas links work for people signed in to your Brew workspace. There is no public share link, so invite your reviewers.
This is where problems surface: two logos, a dead button link, three footers. Run audit_email before fixing anything. It checks links, images, compliance, loaded size, client support, accessibility and copy, costs 5 credits per complete audit, and accepts a saved emailId or raw HTML, so you can audit emails you haven't imported. Our pre-send QA checklist explains each check and threshold.
You now have an inventory, every email on a canvas and a list of issues, and your code still sends everything as before.
Now Brew sends some emails instead of your code. Not every email has to move, and only one system sends a given email type at any time.
| In your code today | In Brew |
|---|---|
resend.emails.send({ react: <WelcomeEmail /> }) |
A trigger event plus a published automation with one Send Email step |
| Props passed to the component | Trigger payload fields, used as merge tags such as {{ firstName | there }} or {{ trigger.planName }} |
The from domain |
A verified sending domain in Brew |
| Your users table | Contacts, created through the REST API or an integration |
| A cron job that sends a sequence | An automation with Wait, Filter and Send Email steps |
The agent calls create_domain for the DNS records, then verify_domain once they're live. MCP and API domains are marketing domains. For password resets and receipts, set up a transactional-purpose domain in the Brew app: it skips the unsubscribe footer and delivers to unsubscribed contacts. Verifying your sending domain covers the records.
MCP updates contacts but doesn't create them. Create them from your backend with POST /v1/contacts, which upserts one contact or a batch of up to 1,000 and creates custom fields automatically, or upload a CSV in the app. Firing a trigger also creates or updates the contact in its payload. Only import people who agreed to hear from you. Adding contacts explains what Brew expects.
Easy to miss: the imported welcome email literally says "Hi Ana." Replace each fixture value with a merge tag such as {{ firstName | there }}, where the text after the bar is the fallback. Use the Brew editor, or have the agent run edit_email limited to those replacements. It's an AI edit that uses credits, so check the preview. Merge tags lists the syntax.
The agent creates a trigger with save_trigger. The payload schema must include the recipient's email. Give it a typed contract (infer_payload_contract drafts one from an example payload, set_trigger_contract saves it), then fetch it with get_trigger_contract in ts or zod format so your backend and Brew agree on the payload shape.
save_automation then builds the trigger plus a Send Email step with the email, domain and subject. It stays a draft until you publish. dryRun validates the graph, test_automation sends a real copy to your address, and check_trigger confirms a published automation is listening before production fires anything.
This is the only change to your application code. Before, with Resend:
await resend.emails.send({
from: 'Acme <hello@acme.com>',
to: user.email,
subject: 'Welcome to Acme',
react: <WelcomeEmail firstName={user.firstName} planName={plan.name} />,
})After, with Brew:
const res = await fetch(
`https://brew.new/api/v1/automations/triggers/${process.env.BREW_WELCOME_TRIGGER_ID}/fire`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.BREW_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': `welcome-${user.id}`,
},
body: JSON.stringify({
payload: { email: user.email, firstName: user.firstName, planName: plan.name },
}),
}
)
if (!res.ok) {
// 422 means the automation is still a draft; 400 means the payload failed the schema.
throw new Error(`Brew trigger failed: ${res.status} ${await res.text()}`)
}fetch resolves even on rejection, so check res.ok and let your job runner retry or alert. The endpoint returns 202 when it accepts the event and 422 if no published automation listens to the trigger.
Build the Idempotency-Key from a stable id, such as user or order. A repeated key returns the original run instead of sending twice, and reusing a key with a different body gets 409 IDEMPOTENCY_CONFLICT. Brew keeps a fire's key as long as the trigger receipt, which is 90 days, not the 24 hours many APIs use. Treat keys as permanent, one per action. A timestamp key changes on every retry, and a key reused for a different action weeks later can be replayed instead of sent.
Production code calls this endpoint with an API key, not MCP. Over OAuth, MCP asks a person to confirm live sends, trigger fires and publishing: right for a chat, wrong for a request handler. The MCP or REST guide covers the split.
EMAILS.md.The Loops migration guide uses the same pattern.
Supabase confirmation and magic-link emails keep coming from Supabase. Brew's Supabase integration listens for user events to start automations rather than replacing Supabase Auth's emails. An itemized invoice built from complex data at send time may also be simpler in code, so check what your merge tags need first. Keep imported copies on the canvas so these stay visible.
Audit the whole set. Run audit_email across a group, fix, and rerun before large sends.
Check real clients. test_email_rendering returns screenshots from specific inbox clients and devices, including dark mode, for 10 credits per run. create_inbox_placement_test sends a real copy to seed inboxes and reports where it landed, also 10 credits.
Make the set consistent. Fix inconsistencies in the editor, or have the agent apply one change, such as a new footer, across a group with edit_email.
Read the results. get_email_analytics reports sends and engagement. list_insights returns findings Brew has detected in your account, each with the metrics behind it.
Test variants deliberately. Create variants on the canvas and route contacts with a Split step based on behavior, such as whether they clicked. Brew doesn't pick a random-split winner for you, so compare variants yourself.
Keep new emails visible. Add a line to AGENTS.md or similar: new product emails are created in Brew, fired by trigger, and added to EMAILS.md in the same pull request.
Yes. import_email accepts rendered HTML, MJML, a saved .eml message or a React Email module. Rendered HTML is the most reliable, because a module is read statically: props become their default or preview values, and components imported from other local files are left out.
No. There is no Resend integration, import or export. You import the HTML your code renders, and if you migrate, your code fires a Brew trigger instead of calling Resend.
No. Use MCP from a coding agent for setup, imports, audits and testing. Production code should call the REST API with an API key, because on OAuth connections MCP asks a person to confirm live sends and trigger fires.
Import converts the email into an editable Brew design and keeps the original source HTML for delivery. It strips scripts, iframes, forms and event handlers, neutralizes existing tracking pixels and rehosts public images. Review each one on the canvas, in desktop and mobile views, before sending it from Brew.
