Send email from GitHub Copilot

Copilot completes from patterns it has seen, and most email code it has seen is SMTP with a hardcoded password. Pointing it at the reference changes the output: one HTTPS call, a key from the environment, and code that survives deployment to a serverless host.

Paste into Copilot Chat

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 Copilot Chat
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.

Requirements:
- Use the @emails.sh/sdk package (npm install @emails.sh/sdk). Do not use nodemailer or SMTP.
- Read the key from process.env.EMAILSSH_API_KEY. Never hardcode a key, never put it in a client-side file, and if it is not set throw an error naming the variable.
- Put the send in server-side code and call it from the signup handler so a new user gets a verification email.
- Send from onboarding@emails.sh until my own domain is verified.
- Show me the full file, not a fragment.
01

Copilot fetches the reference before completing.

Given the .md URL in the chat, it works from the real field names rather than the nearest pattern in its training data.

02

It adds the dependency.

`npm install @emails.sh/sdk`, which you run in the integrated terminal. Copilot proposes the command, you approve it.

03

It writes the module and the call site.

A server-side send function plus the line in your signup handler that calls it, with the key read from the environment in one place.

04

You set the variable everywhere it runs.

Local .env for development, and the host environment for production and preview. Missing it is the most common cause of "works on my machine".

What Copilot writes

src/email.ts, with the password reset send your auth flow calls.

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

const apiKey = process.env.EMAILSSH_API_KEY;
if (!apiKey) throw new Error('EMAILSSH_API_KEY is not set');

const emails = new Emailssh(apiKey);

export async function sendPasswordReset(to: string, token: string) {
	const link = `https://acme.com/reset?token=${token}`;

	const { id } = await emails.send({
		from: 'Acme <hello@acme.com>',
		to: [to],
		subject: 'Reset your password',
		// A reset mail needs both parts. Some clients and most security
		// scanners read the text version, and an HTML-only mail scores worse.
		html: `<p>Someone asked to reset your password. The link expires in an hour.</p>
<p><a href="${link}">Reset your password</a></p>
<p>If this was not you, ignore this email.</p>`,
		text: `Someone asked to reset your password. The link expires in an hour: ${link}

If this was not you, ignore this email.`,
		tags: { type: 'password-reset' },
		idempotencyKey: `reset:${token}`
	});

	return id;
}

Worth knowing

01

Its first instinct is nodemailer and an app password

That is the most common shape in public repositories, so it is what completion offers. It works locally and fails on Vercel, Netlify, and Cloudflare Workers, where outbound SMTP is blocked.

02

Inline completion will happily type a fake key

A literal starting with esh_ looks convincing enough to commit. Read the key from process.env every time and let a missing value throw.

03

It completes from the file it can see

If the open file is a client component, the completion is a client-side fetch with your key in it. Open the server file first, or say server-side in the chat.

04

Ask for the whole file

Copilot Chat truncates to the interesting part, which is how imports go missing. Requesting the full file gives you something that runs unedited.

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:      member@example.com
Subject: Reset your password

Someone asked to reset your password. The link expires in an hour: https://acme.com/reset?token=77af1e

Questions

Can Copilot read the emails.sh docs?

Copilot Chat can fetch a URL you give it. https://emails.sh/docs.md is the docs page served as markdown, which is cheaper to read than the HTML.

Why not SMTP?

Most serverless hosts block outbound SMTP ports, so an SMTP integration passes locally and fails after deployment. HTTPS is not blocked anywhere.

Does this work in Visual Studio and JetBrains too?

Yes. The output is an ordinary npm package and one HTTPS call, so nothing depends on the editor.

What is the response I get back?

{ "id": "...", "status": "queued" }. Keep the id: GET /v1/emails/:id returns delivery events for that exact message.