Send email from Windsurf

Windsurf edits multiple files in one pass and can run commands, so transactional email is a single instruction. Tell it where the reference lives and which variable to use, and the result is a send that works locally and on your host without a second pass.

Paste into Windsurf Cascade

Copy this. The page it names is served as markdown at that exact URL, so the assistant reads the real integration rather than guessing at an API shape.

Paste into Windsurf Cascade
Add transactional email to this project using emails.sh.

Read https://emails.sh/docs.md first so you use the real request shape instead of guessing it.

1. Run `npm install @emails.sh/sdk` in the project root.
2. Add EMAILSSH_API_KEY to the env file this project already uses, and check that file is gitignored. If I have not given you a key, stop and tell me to create one at https://emails.sh/dashboard instead of inventing a placeholder.
3. Add one server-side module that builds the client from process.env.EMAILSSH_API_KEY and exports a send function. Do not call the API from client code.
4. Call it from the signup handler to send a verification email, from onboarding@emails.sh until my domain is verified.
5. List the files you changed and the environment variables I still need to set on my host.
01

Windsurf reads the project layout.

It finds where server code lives in this stack and puts the send there, rather than in whichever file you happened to have open.

02

It installs and configures in one pass.

`npm install @emails.sh/sdk`, EMAILSSH_API_KEY in the existing env file, ignore rule confirmed.

03

It writes the send once and reuses it.

A single module, imported wherever mail is needed, so the key is read in exactly one place.

04

It reports what you still have to do.

The one manual step is setting EMAILSSH_API_KEY on your host, because nothing it does locally can do that for you.

What Windsurf writes

server/email.ts, imported by every handler that sends mail.

What Windsurf writes
import { Emailssh } from '@emails.sh/sdk';

const emails = new Emailssh(process.env.EMAILSSH_API_KEY!);

interface Receipt {
	email: string;
	orderId: string;
	amount: string;
}

export async function sendReceipt({ email, orderId, amount }: Receipt) {
	const { id } = await emails.send({
		from: 'Acme <billing@acme.com>',
		to: [email],
		// reply_to matters on a receipt: the reply goes to a person, not to a
		// no-reply address the customer has to work around.
		replyTo: 'support@acme.com',
		subject: `Receipt for order ${orderId}`,
		html: `<p>Thanks for your order.</p><p>Order ${orderId}, ${amount}.</p>`,
		text: `Thanks for your order. Order ${orderId}, ${amount}.`,
		tags: { type: 'receipt' },
		// One receipt per order, even if the payment webhook is delivered twice.
		idempotencyKey: `receipt:${orderId}`
	});

	return id;
}

Worth knowing

01

A multi-file edit can put the key in two places

Windsurf editing several files at once sometimes reads the environment variable in more than one module. One module owns the client, everything else imports it.

02

It defaults to SMTP if you do not name a provider

Ask for "email" and you may get nodemailer with Gmail, which works locally and fails on serverless hosts that block outbound SMTP ports. Name emails.sh in the instruction.

03

The docs URL is what prevents an invented API

Fields differ between providers. https://emails.sh/docs.md is the same page as the HTML docs, served as markdown, so the fetch is cheap and the shape is right.

04

Preview and production are separate

Setting the variable locally does not set it on your host, and preview environments usually need it added on their own.

05

Your assistant can run the account, not just write the code

There is an MCP server at https://mcp.emails.sh. Connect it and the assistant gets 19 tools for the things you would otherwise alt-tab to a dashboard for: add a domain and read back the exact DNS rows, trigger a verification check, mint or revoke a scoped key, send a test message, read a delivery timeline, work out why something bounced, list and lift suppressions, and create, test, or replay a webhook. Authenticate with an esh_ key as a bearer token, or with OAuth. Revoking a key and lifting a suppression are marked destructive and need an explicit confirmation before they run.

06

It can write your lifecycle sequences as a file

An automation here is a YAML document: a trigger, an optional filter, and a list of steps that each send, wait, or branch. GET https://emails.sh/v1/automations/<id>.yaml returns it, PUT the same path replaces it, and a document you push is stored as the exact bytes you sent. So an assistant can write a trial sequence into your repository, you review the diff like any other change, and CI pushes it on merge. Errors from the parser name the wrong thing, say what to write instead, and give a line number, which is what lets an assistant correct itself. Note that this runs over the REST API rather than over MCP: there are no automation tools on the MCP server.

What arrives

One call to POST /v1/emails, and this is the message. The delivery result for it is on GET /v1/emails/:id a second later.

Sent
To:      customer@example.com
Subject: Receipt for order 10428

Thanks for your order. Order 10428, $24.00.

Questions

Does Windsurf need the skill?

No. Fetching https://emails.sh/docs.md gives it the API. The skill is a shortcut for Claude Code and Cursor that skips the fetch.

What if it writes the send into a React component?

Tell it to move the call to server code and revoke the key at https://emails.sh/dashboard, because a key in a client bundle is public from the moment it deploys.

Can I keep my existing SMTP setup alongside this?

Yes, but there is little reason to. One API call replaces the SMTP connection, the credentials, and the port that your host blocks.

How long does domain verification take?

Two DNS records, and verification completes once they propagate, usually within the hour. Until then send from onboarding@emails.sh.