How to send email from Cursor

Ask Cursor for a signup flow and you get a signup flow. Ask it to send the verification email and you usually get nodemailer wired to a Gmail account, which stops working the moment the app leaves your laptop. Here is the setup that produces something deployable instead.

3 min read

Put the constraint in a rules file

Cursor reads project rules before it writes anything, so this is the highest-leverage file in the whole integration. Create .cursor/rules/email.mdc and the agent stops guessing at a transport.

.cursor/rules/email.mdc
---
description: How this project sends email
alwaysApply: true
---

Transactional email goes through emails.sh over HTTPS.

- Endpoint: POST https://emails.sh/v1/emails
- Auth: Authorization: Bearer <EMAILSSH_API_KEY from the environment>
- Body: { from, to[], subject, html, text, reply_to, idempotency_key }
- Response: { id, status: "queued" }

Never use SMTP, nodemailer, or a mail transport library. Serverless
functions cannot hold an SMTP connection open reliably.

Always send both html and text. Always await the send and handle a
non-2xx response. Never inline an API key.

Reference: https://emails.sh/docs.md

The file is committed, so everyone on the team gets the same behaviour and the next agent session starts from the same place rather than from the training data average.

Get a key without leaving the editor

Run this in Cursor's terminal. It creates a scoped key and writes it to .env.local.

Shell
npx @emails.sh/cli login
npx @emails.sh/cli keys create --name "cursor"

Confirm .env.local is in .gitignore first. Cursor indexes your working tree for context, and anything in the tree can end up quoted back into a chat transcript.

The prompt

With the rules file in place, the prompt can be short. Without it, say all of this every time.

Add a verification email to the signup flow. Use emails.sh over HTTPS
per the project rules. Send from onboarding@emails.sh for now. Include
an idempotency_key derived from the user id and the token so a retry
cannot send twice. Show me the diff before applying.

"Show me the diff before applying" is worth typing. Email code touches secrets and sends things to real people, which puts it in the small category of generated code you should read line by line.

What good output looks like

lib/email.ts
type SendResult = { id: string; status: string };

export async function send(input: {
  to: string;
  subject: string;
  html: string;
  text: string;
  idempotencyKey?: string;
}): Promise<SendResult> {
  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: [input.to],
      subject: input.subject,
      html: input.html,
      text: input.text,
      idempotency_key: input.idempotencyKey
    })
  });

  if (!res.ok) throw new Error(`emails.sh ${res.status}: ${await res.text()}`);
  return res.json() as Promise<SendResult>;
}

One function, one place the key is read, one place to add logging later. If the agent scattered fetch calls across four route handlers, ask it to extract them before you move on.

Review checklist

Look forWhy
No esh_ string anywhere in sourceA key in the repo is a key you must revoke
No createTransport, no port 587SMTP will not survive the deploy
await on every sendA dropped promise in a serverless function silently never runs
A text body alongside the HTMLHTML-only mail is scored worse by filters
An idempotency_key on anything a user can retryPrevents duplicate verification and reset mail

Then check that mail actually arrives

Accepted is not delivered. Send one message to a real address you control and read the events back.

Shell
curl -s https://emails.sh/v1/emails/em_2t9x4k1c7v \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"

If it landed in spam rather than the inbox, the code is fine and the DNS is not. That is a separate job, and it is the same job for every provider: SPF, DKIM, and DMARC explained for developers, then run your domain through the mail tester.

The same thing in other assistants

The pattern generalises: a rules or memory file that names the transport, a key in the environment, and a review pass. See how to send email from Claude Code for the Claude Code version, and the per-assistant pages for Cursor, GitHub Copilot, Replit, and Lovable.

Questions

Where does Cursor look for project rules?
In .cursor/rules/*.mdc. A rule with alwaysApply: true is included in every request for that project, which is what you want for a constraint like "never use SMTP".
Can Cursor send an email during a chat session?
Not by itself. It writes code that your application runs. If you want a message sent right now, run the curl command in the terminal rather than asking the agent to imagine one.
Why does the agent keep choosing nodemailer?
Because it is the most common answer in the training data for sending mail in Node. A rules file that forbids it and names the HTTPS endpoint overrides that default reliably.
Do I need a verified domain to start?
No. Send from onboarding@emails.sh while you build. Verify your own domain before real users see the mail, because a sandbox sender address in a production email looks like a phishing attempt.

Give your agent an address it can answer from.

Create an inbox