How to send email from Claude Code
Claude Code will happily write email-sending code for you. Left to guess, it usually reaches for nodemailer and an SMTP host, which is the one shape that breaks after you deploy. This is how to point it at something that works on the first run.
3 min read
Give it the two facts it cannot invent
An assistant can write the code. It cannot know your API key or your verified sending domain. Everything else it can derive if you hand it a page it can actually read.
npx @emails.sh/cli login
npx @emails.sh/cli keys create --name "claude-code"The CLI writes the key to .env.local and prints it once. Add .env.local to .gitignore before you run it, because an assistant that later greps your repo for context will read whatever is in the working tree.
The prompt that produces a working integration
Vague prompts produce SMTP. Name the transport and the endpoint and you get an HTTPS call.
Add transactional email to this app using emails.sh.
Read https://emails.sh/llms.txt first, then https://emails.sh/docs.md.
Requirements:
- POST https://emails.sh/v1/emails over HTTPS. Do not use SMTP or nodemailer.
- Read the key from process.env.EMAILSSH_API_KEY. Never inline it.
- Send from onboarding@emails.sh until I tell you the domain is verified.
- Every send needs both html and text.
- Await the send and handle a non-2xx response, do not fire and forget.
Start with the signup confirmation email.Every page on the site is served as markdown at the same path with .md appended, so https://emails.sh/with/next.js.md is the Next.js guide without the HTML around it. That matters here: an assistant fetching a rendered marketing page burns context on navigation, and an assistant fetching the markdown gets the code sample.
Install the skill and skip the fetch entirely
A skill is a folder Claude Code loads into context when the task looks relevant. Installing the emails.sh skill means the API surface is already known and no fetch happens at all.
npx @emails.sh/cli skill installThat writes into .claude/skills/ in the current project. Commit it if you want everyone on the team to get the same behaviour, and re-run it after a major SDK release.
What the generated code should look like
Check the diff against this. If it has an SMTP transport, a createTransport call, or a port number in it, ask for it again.
export async function sendConfirmation(to: string, link: string) {
const res = await fetch('https://emails.sh/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.EMAILSSH_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
from: 'Acme <hello@acme.com>',
to: [to],
subject: 'Confirm your email',
html: `<p>Confirm your address: <a href="${link}">${link}</a></p>`,
text: `Confirm your address: ${link}`,
idempotency_key: `confirm:${to}:${link}`
})
});
if (!res.ok) throw new Error(`send failed: ${res.status} ${await res.text()}`);
return (await res.json()) as { id: string; status: string };
}The idempotency_key is the line an assistant will leave out unless you ask. It is what stops a retried request from sending a second confirmation, and the reasoning behind it is in idempotency keys and retries for transactional email.
Reviewing what it wrote
Four checks, in the order they usually fail.
| Check | What you are looking for | What it means if it fails |
|---|---|---|
grep -r "esh_" src/ | No hits | A key was inlined into source |
grep -ri nodemailer . | No hits | It reached for SMTP and will time out in production |
Search for await on the send | Present | A floating promise in a serverless function never finishes |
A text field on every send | Present | HTML-only mail scores worse with spam filters |
Let it verify its own work
The delivery log is readable over the same API, so the assistant can confirm a send arrived rather than asserting that it should have.
curl -s https://emails.sh/v1/emails/em_2t9x4k1c7v \
-H "Authorization: Bearer $EMAILSSH_API_KEY"The response carries the delivery events for that message. Ask Claude Code to send one test message and then poll that endpoint until the status stops being queued. It closes the loop without you opening a dashboard.
Before you go live
The sandbox address gets you a working flow. Real mail from your own domain needs the authentication records set up, which is a DNS task no assistant can do for you because it does not have your registrar login. Work through SPF, DKIM, and DMARC explained for developers once, then check the result with the DMARC checker.
The same approach works in other assistants: see how to send email from Cursor, the Windsurf page, and the Claude Code page. If you are building this way full time, the vibe coders page collects the prompts that hold up.
Questions
- Does Claude Code itself send the email?
- No. Claude Code writes code into your project and your application sends the mail at runtime, using the REST API and a key from the environment. If you want a message sent right now, run the curl command in the terminal.
- How do I stop Claude Code from putting my API key in the repo?
- Add
.env.localto.gitignorebefore you create the key, tell the assistant to read it fromprocess.env, and rungrep -r "esh_" src/before you commit. Revoke and reissue any key that has ever been committed. - Why does the assistant keep writing nodemailer code?
- Because nodemailer dominates the training data for "send email in Node". Name the constraint explicitly in the prompt: HTTPS API, no SMTP, no nodemailer. Installing the skill removes the guesswork entirely.
- Can Claude Code verify a sending domain for me?
- Not on its own. It can print the exact DNS records to add, but adding them means logging into your registrar. Once they are in, ask it to poll
GET /v1/domainsuntil the domain reports as verified.
Give your agent an address it can answer from.
Create an inbox