---
title: How to send email from Claude Code
metaTitle: How to send email from Claude Code
description: Give Claude Code everything it needs to add transactional email to your app in one pass: the key, the endpoint, the prompt, and the checks that catch a wrong integration.
date: 2026-07-21
author: emails.sh
tags: Guides
---

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.

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

```bash
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.

```text
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.

```bash
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.

```ts
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](/blog/idempotency-keys-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.

```bash
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](/blog/spf-dkim-dmarc-explained) once, then check the result with the [DMARC checker](/tools/dmarc-checker).

The same approach works in other assistants: see [how to send email from Cursor](/blog/send-email-from-cursor), the [Windsurf page](/for/windsurf), and the [Claude Code page](/for/claude-code). If you are building this way full time, [the vibe coders page](/for/vibe-coders) 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.

## Related

- [How to send email from Cursor](/blog/send-email-from-cursor)
- [Idempotency keys and retries for transactional email](/blog/idempotency-keys-transactional-email)
- [SPF, DKIM, and DMARC explained for developers](/blog/spf-dkim-dmarc-explained)
- [How to send email in Next.js with the App Router](/blog/send-email-nextjs-app-router)
- [Claude Code integration](/for/claude-code)
- [Windsurf integration](/for/windsurf)
- [For vibe coders](/for/vibe-coders)
