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.

Shell
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.

Shell
npx @emails.sh/cli skill install

That 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.

TypeScript
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.

CheckWhat you are looking forWhat it means if it fails
grep -r "esh_" src/No hitsA key was inlined into source
grep -ri nodemailer .No hitsIt reached for SMTP and will time out in production
Search for await on the sendPresentA floating promise in a serverless function never finishes
A text field on every sendPresentHTML-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.

Shell
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.local to .gitignore before you create the key, tell the assistant to read it from process.env, and run grep -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/domains until the domain reports as verified.

Give your agent an address it can answer from.

Create an inbox