# Sending email

Your functions send transactional email with `ctx.email.send`: receipts, sign-in links, notifications. Bulk marketing and newsletters aren't allowed, and the limits below are set for one email per person per event.

```ts
import { fn } from "@flatstack/server";

export const sendReceipt = fn({
  auth: "required",
  handler: async ({ ctx }) => {
    await ctx.email.send({
      to: ctx.user.email,
      subject: "Your receipt",
      html: "<p>Thanks for your order.</p>",
      text: "Thanks for your order.",
    });
  },
});
```

| Field | What it does |
| --- | --- |
| `to` | One address or a list of up to 50. Each recipient gets their own copy and never sees the others. |
| `from` | `orders@example.com` or `Shop <orders@example.com>`, on a [verified sender domain](email-domains.md). Leave it out to send from `<project>@mail.flatstack.app`. |
| `replyTo` | One address or a list. |
| `subject` | Required unless the template has one. |
| `html`, `text` | The body. Give one or both, or a `template`. |
| `template`, `data` | A saved template and the values for its placeholders. |
| `attachments` | Up to 10 of `{ filename, content, contentType }`, with `content` in base64. |
| `kind` | `auth` for verification and password-reset emails, which keep sending past the email limit. Anything else is `app`. |

`send` returns `{ id, messages }` with one entry per recipient. Its `status` is `sent`, `logged` (not sent from this environment, see below), `suppressed` (the address is on your suppression list) or `failed` (the mail provider refused it; the email log says why).

### React Email and MJML

Render to HTML first and pass `html`, for example `html: await render(<Welcome name={name} />)` with `@react-email/render`. Flatstack never runs template code.

## Sender address

Until you verify a domain, emails come from `<project>@mail.flatstack.app`, and replies go to `replyTo` if you set one. A `from` on a domain that isn't verified for the project fails with `email_domain_not_verified`.

When a verified domain stops verifying (its DNS record is gone), or for the few minutes after you rotate its key, emails go from the default address with your `from` as the reply-to. They never go out unsigned or signed with an old key.

## Limits

| Limit | Hobby | Pro | Studio | What happens |
| --- | --- | --- | --- | --- |
| Emails a month | 3,000 | 15,000 | 50,000 | At 80% owners get an email. At 100%, sends fail with `email_quota_exceeded` (429) until the period resets; `auth` emails keep sending up to 110%. An email pack adds 25,000 for the period. |
| Sends a minute | 20 | 60 | 200 | Over it, sends fail with `email_rate_limited` (429); wait a minute. A send to several recipients counts once. |
| Recipients a send | 50 | 50 | 50 | Over it, the send fails with `email_too_many_recipients` (400). |

Each recipient SES accepts counts as one email towards the monthly limit. Logged, suppressed and failed recipients don't count.

## Preview environments and sandboxes

Email from a preview environment goes only to your account members' addresses. Every other recipient is logged with status `logged` and the reason `members_only`, and nothing is sent. Email from an unclaimed sandbox is always logged, never sent.

## Templates

Save a template, preview it, and send yourself a test:

```bash
curl -X PUT https://api.flatstack.dev/v1/projects/$PROJECT/email/templates/welcome \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"subject":"Welcome, {{ name }}","html":"<h1>Hi {{ name }}</h1>","text":"Hi {{ name }}"}'

curl -X POST https://api.flatstack.dev/v1/projects/$PROJECT/email/templates/welcome/preview \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"data":{"name":"Ada"}}'
```

Placeholders are `{{ name }}`, and `{{ user.name }}` reads nested data. Values are HTML-escaped in the HTML part. A missing value renders as nothing. Template names use lower-case letters, digits, `-` and `_`.

`POST …/templates/{name}/test` sends the template to the signed-in member's own address only, so it needs a dashboard session rather than a token.

## Email log

`GET /v1/projects/{projectId}/email/messages` lists recent sends, newest first, one row per recipient: sender, recipient, subject, template, status and the provider's reason for a failure or bounce. Add `limit` (up to 200) and `before` (an ISO time) to page back. Email bodies are never stored. The log keeps 30 days.

Statuses after sending: `delivered`, `bounced` (a hard bounce) or `complained` (the recipient marked it as spam). Opens and clicks aren't tracked.

## Suppression list

A hard bounce or a complaint adds the address to the project's suppression list, and later sends to it are skipped with status `suppressed`. You can add addresses yourself:

| Request | What it does |
| --- | --- |
| `GET …/email/suppressions` | Lists addresses with the reason: `bounce`, `complaint` or `manual`. |
| `POST …/email/suppressions` `{"email":"…"}` | Adds an address. |
| `DELETE …/email/suppressions/{email}` `{"confirm":true}` | Removes one, so mail reaches it again. Needs `confirm`. |

## Suspension and appeals

To protect delivery for every customer, sending pauses for a project when more than 5% of its last 500 sends hard-bounced, or more than 0.1% got complaints. A project with fewer than 500 sends is measured against 500, so a few early bounces don't pause it. Owners get an email, and sends fail with `email_sending_suspended` (403).

`GET …/email/settings` shows `sendingStatus` (`active`, `suspended` or `appealed`) and the rates that caused it. When you've fixed the cause, appeal:

```bash
curl -X POST https://api.flatstack.dev/v1/projects/$PROJECT/email/appeal \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"message":"A sign-up form was being spammed. We added a captcha and removed the bad addresses."}'
```

We review appeals by hand. Sending stays paused until we do. When we reinstate a project, its rates start from zero.

## Your own SES (BYO SES)

Send through your own Amazon SES account and Flatstack's monthly email limit no longer applies to the project. The per-minute rate still does.

```bash
curl -X PUT https://api.flatstack.dev/v1/projects/$PROJECT/email/byo-ses \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"region":"eu-west-1","accessKeyId":"AKIA…","secretAccessKey":"…"}'
```

The access key needs `ses:SendEmail` only. The secret is stored encrypted and is never shown again. With your own SES:

- Every send needs a `from` on a domain or address your SES account has verified. Your SES checks it, not Flatstack.
- Bounces and complaints go to your SES, not to the Flatstack email log or suppression list, and don't count towards suspension.

`DELETE …/email/byo-ses` goes back to Flatstack's SES.

## Errors

| Code | Status | Meaning |
| --- | --- | --- |
| `email_quota_exceeded` | 429 | The account used its monthly emails. |
| `email_rate_limited` | 429 | Too many sends this minute. |
| `email_too_many_recipients` | 400 | More than 50 recipients in one send. |
| `email_domain_not_verified` | 422 | `from` uses a domain that isn't verified for the project. |
| `email_sending_suspended` | 403 | Sending is paused for bounces or complaints. |
| `email_send_failed` | 502 | The provider refused every recipient. The email log has the reasons. |
| `email_appeal_not_open` | 409 | Sending isn't suspended, so there's nothing to appeal. |