# Send emails with Convex

> Install the Convex component and call sendEmail.

## What you'll build

A Convex mutation that enqueues a welcome email. The PostShiba component commits the email with your transaction, then delivers it from a background action with retries.

## Before you start

- An API key with **Send only** access. **Full access** also works. See [Create an API key](/docs/create-an-api-key). Support keys cannot send.
- A verified sending domain. See [Add and verify a domain](/docs/add-a-domain). `from` must be an address on it.
- A cluster that is ready to send (`sending_ready` is `true`). See [Clusters](/docs/dashboard/clusters/introduction).
- Your team id and cluster id. Both are on the **API keys** page.

## Install

```sh install.sh
npm install @postshiba/convex
```

## Send your first email

1. Register the component in `convex/convex.config.ts`.

```ts convex.config.ts
import { defineApp } from "convex/server"
import postshiba from "@postshiba/convex/convex.config"

const app = defineApp()
app.use(postshiba)

export default app
```

2. Create the client and call `sendEmail` from a mutation in `convex/email.ts`.

```ts email.ts
import { mutation } from "./_generated/server"
import { v } from "convex/values"
import { PostShiba } from "@postshiba/convex"
import { components } from "./_generated/api"

export const postshiba = new PostShiba(components.postshiba, {
	apiKey: "YOUR_API_KEY",
	teamId: "KjkAJW",
	clusterId: "NmQpXr",
	testMode: false,
})

export const sendWelcome = mutation({
	args: { to: v.string() },
	returns: v.string(),
	handler: async (ctx, args) => {
		return await postshiba.sendEmail(ctx, {
			from: "hello@mail.example.com",
			to: args.to,
			subject: "Welcome",
			text: "Glad you are here.",
			html: "<p>Glad you are here.</p>",
		})
	},
})
```

Run it with `npx convex run email:sendWelcome '{"to": "you@example.com"}'`. `sendEmail` returns an `EmailId` once the email is stored as `queued`. Delivery happens after the mutation commits, and the message then shows under Events in the dashboard.

You can leave out `apiKey`, `teamId`, and `clusterId` and set `POSTSHIBA_API_KEY`, `POSTSHIBA_TEAM_ID`, and `POSTSHIBA_CLUSTER_ID` with `npx convex env set` instead.

Two options matter before you send for real:

| Option | Default | Why it matters |
| --- | --- | --- |
| `testMode` | `true` | While on, every recipient must be on `inbound.postshiba.com`. Set it to `false` to send to real addresses. |
| `hourlyLimit` | `25` | The component sends at most this many emails an hour. Raise it to just under your cluster's hourly capacity. |

## Attachments and templates

### Attach a file

Pass `attachments` with `filename`, `contentType`, and base64 `content`. Up to 10 files, 18 MB in total. See [Attachments](/docs/dashboard/emails/attachments) for allowed types.

```ts attachment.ts
await postshiba.sendEmail(ctx, {
	from: "hello@mail.example.com",
	to: "you@example.com",
	subject: "Your photo",
	text: "See attached.",
	attachments: [
		{
			filename: "photo.png",
			contentType: "image/png",
			content: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
		},
	],
})
```

### Send a published template

Pass `template` with the alias or public id and its `variables`. Leave out `html` and `text`.

```ts template.ts
await postshiba.sendEmail(ctx, {
	from: "hello@mail.example.com",
	to: "you@example.com",
	template: { id: "welcome", variables: { name: "Ada" } },
})
```

## Keep the key safe

Use a **Send only** key in your deployment. It can send email and call `GET /api/v1/users/me`. Every other request returns `403` `forbidden`.

| If this key leaks | Send only | Full access |
| --- | --- | --- |
| Send mail from your verified domains | Yes | Yes |
| Read events, recipients, templates, and sent messages | No | Yes |
| See SMTP passwords and webhook secrets | No | Yes |
| Change clusters, domains, credentials, webhooks, and suppressions | No | Yes |

- Keep Full access keys for setup scripts, the [CLI](/docs/send-with-cli), and [MCP](/docs/connect-mcp). Don't set one on a Convex deployment.
- Set the key with `npx convex env set POSTSHIBA_API_KEY YOUR_API_KEY` and leave `apiKey` out of `new PostShiba`. Don't commit it, log it, or put it in a URL.
- The component sends from a background action, so the key stays in your deployment. Never pass a key, even a Send only key, from your React or mobile client.
- Create one key per deployment, so dev and prod never share one. If a key leaks, delete it on **API keys** and create a new one. Deleting stops it straight away.

## Troubleshooting

Errors do not reach your mutation, because delivery runs later. Read the outcome with `postshiba.status(ctx, emailId)`, or subscribe to webhooks with `handleEventWebhook`.

| Status | `error` | What the component does |
| --- | --- | --- |
| `401` | `Invalid token or no user signed in` | Marks the email `failed` without retrying. Check `apiKey` or `POSTSHIBA_API_KEY`. |
| `403` | `forbidden`, `domain_unverified`, or `kyc_required` | Marks the email `failed`. Support keys cannot send, so use a Send only key and a verified `from`. While your team is pending production approval, send only to team members, live inbox addresses, and inbox forward-to addresses. |
| `422` | `invalid` | Marks the email `failed`. `errorCode` and `errorMessage` on the email say why. |
| `429` | `throttled` | Retries with backoff, because the component owns the queue. Keep `hourlyLimit` under your cluster's capacity so this stays rare. See [What happens at your cap](/docs/dashboard/clusters/what-happens-at-your-cap). |

Every other status is on [Errors](/docs/api-reference/errors).

## Next steps

- [Verify webhook requests](/docs/webhooks/verify-webhooks-requests) before you route events to `handleEventWebhook`.
- [Event types](/docs/webhooks/event-types) to see which statuses the component can record.
- [Send emails with Node.js](/docs/send-with-nodejs) for the plain client.
- [SDKs](/docs/sdks) for every language client.
