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.
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. 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:
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:
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.
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
fromon 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. |
As Markdown: apps/email.md