Skip to content

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

As Markdown: apps/email.md