Your app sends three kinds of email from your own domain: sign-in codes, notifications your server sends a person, and your answers from the Inbox. Replies land in the Inbox.
An order ships, and your server tells the person:
import { gemmeinServer } from "@gemmein/sdk";
const server = gemmeinServer(process.env.GEMMEIN_SECRET_KEY);
await server.notify(order.ownerUserId, {
subject: "Your poster is on its way",
text: "It left the studio this morning. Reply to this email if it hasn't arrived by Friday.",
key: `shipped:${order.id}`,
});
// → { sent: true, id, threadId, replyRail: true }
What it is. All email your app sends leaves from your domain once it is verified
on the Domains page with a purpose of email or both. Each email goes out as
<word>@yourdomain (see Sender addresses), and a
person who answers reaches your Inbox. Every email is plain text, carries a footer saying
why it was sent, and is rate-capped.
What it does.
- Sends a sign-in code to anyone who signs in.
- Sends a plain-text notification to one of your app's own people when your server calls
notify()or a relay runsemail_person. The footer says why it was sent. Limits: 5 per person per day, 200 per app per hour. - Emails the buyer once per payment after a paid product purchase, Stripe or relay-sold: Your <product> is ready, with a link to your Library (see The purchase email).
- Sends an account notice (
kind: "account") outside the per-person cap. - Keeps every send in your Inbox as a conversation. Once receiving is verified, a reply
lands in the same thread, labelled by the address they replied to. Your Inbox answers go
out from
support. - Sends at most once through retries when you give it a
key.
What it does not.
- Send
notify()oremail_personfrom a Gemmein address. Until your domain is verified, both are refused with409 sender_domain_required: nothing leaves, no cap is spent, and the same key sends once you verify. - Address an email to a string. The recipient is a person id, so someone who never signed in cannot be emailed.
- Send HTML, a template, a campaign or a no-reply. Every kind can be answered.
- Touch the mailbox your domain already has. Receiving is on
mail.yourdomain(forshop.example.com,mail.shop.example.com) unless you choose the domain itself. - Retry a send from a Gemmein address when your provider refuses yours. You get
502 send_failedand may retry.
Needs something else when.
- You want a newsletter or an announcement to everyone at once. Broadcasts are not a Gemmein feature; each send goes to one person.
- You want rich HTML. Send it from your own tool.
- You want to email someone who has never signed in. They must sign in first: a secret key addresses people by id.
- Your sign-in codes arrive as
<App name> (via Gemmein). That is the one email Gemmein sends on your behalf before a domain is verified. Add the domain with a purpose of email and the sign-in code arrives fromaccount.
Example. An order ships; your server tells the person (the code above).
The sending domain
Add your domain on the Domains page and choose what it is for: your web app, email, or both. A domain for email gets all its records at once: SPF and DKIM for sending, and an MX for receiving. The page rechecks them until they are found. A mobile-only app has no web domain and can still have a sending one.
You choose where receiving lives when you add the domain:
- On
mail.yourdomain(forshop.example.com,mail.shop.example.com). Sending and receiving share one record set, and the mail your domain already receives is untouched. - On the domain itself, when its MX is Gemmein's.
Either way, people see your domain in the From line. Reply-To uses the domain too whenever a forward carries it there.
Sender addresses
Each kind of email leaves from its own address on your domain. The Domains page labels each one The word:
| Word | Leaves from it | A reply to it |
|---|---|---|
account | sign-in codes; every notify(), either kind; a relay's email_person; the purchase email | lands in the Inbox labelled account |
support | your own answers from the Inbox | the address people write to |
news | broadcasts; notify(), email_person and the purchase email never use it | lands in the Inbox labelled news |
Each is a real address. Two kinds can never share one address; the dashboard refuses it.
The defaults come from the word you gave the domain when you added it. The
kind of a notify() sets only its cap, never its address.
The purchase email
When someone buys a product, through its Payment Link or a relay's
fulfil_product, Gemmein emails the buyer once for that payment: Your
<product> is ready, from your account address, with a link to your
Library. The link is your app's verified web domain plus the Library path, never the file;
the Library asks the buyer to sign in with the same email.
- On by default. Payments, under Products: Email buyers when they buy
turns it off, and Library path sets where the link opens (
/until you set one). - Sent only from your domain. With no verified sending domain, or no verified web domain to link, it is not sent; nothing leaves from a Gemmein address instead. Logs shows one row a day saying which is missing.
- Your own instead. Turn it off and send yours with
notify(). Keys startinggemmein:are Gemmein's own and answer400 reserved_key.
Development sends
In your cloud Development environment, every notify(), every relay
email_person and every purchase email shares one daily budget: 20 a day per
app and 50 a day per account. The next send answers 429 development_capped
with resetAt. Sign-in codes are outside it, and Live has no such budget. Test
volume locally with npx gemmein dev, which prints each send in the terminal
and mails nothing.
Email templates
The emails Gemmein sends for your app can wear your own design. Write one HTML file per email in
gemmein/emails/<name>.html with {{var}} placeholders, and the sign-in
email's brand in gemmein/emails/sign-in.json. npx gemmein sync saves them in
Development, and go-live and promote copy Development's templates into Live. npx gemmein dev
uses the same files exactly as the cloud does and writes each email to gemmein/.data/mail.
An email with no template sends in Gemmein's plain design.
| File | Used for | Variables |
|---|---|---|
sign-in.json | The sign-in code email. A brand config, not HTML: Gemmein renders the whole email in one fixed layout around its own code. The subject and plain text stay Gemmein's | Keys: logoUrl brandColour backgroundColour appName greeting |
purchase.html | The purchase email | {{app_name}} {{product}} {{library_url}} |
notify.html | Every notify() and relay email_person, unless a more specific template exists | {{app_name}} {{subject}} {{body}} |
notify-account.html, notify-event.html | notify() of that kind | As notify |
email-person.html | A relay's email_person | As notify |
inbox-reply.html | Your Inbox replies and new conversations, around your message | {{app_name}} {{subject}} {{content}} |
- The sign-in brand.
logoUrlis anhttps:image. When you save, Gemmein fetches it once (PNG, JPEG, GIF or WebP, up to 200 KB), keeps its own copy in your app's storage, and the email shows that copy, never your address. Changing the image at your address changes nothing until you save a changedsign-in.json.brandColourandbackgroundColourare#rrggbb; a brand colour too light to read the code on is refused.appName(60 characters) is the header label when there is no logo; the sentence beside the code and the logo's description always use your app's real name.appNameandgreeting(120) are one line of plain text with no digits or numerals and nothing that looks like a web address. Every key is optional. - What an HTML template may hold. Layout and text elements (tables,
div,p, headings, links, images, lists) and CSS for colour, fonts, spacing, borders, sizes and alignment, as plain values. A<style>block is inlined onto your elements;@mediarules stay when every selector names a class. - What Gemmein removes. Scripts, forms, frames, embeds, SVG, media,
<link>,<meta>,<base>, comments, event handlers, CSSurl(),@import,@font-face,calc()and other CSS maths, positioning, transforms and negative values. Links arehttps:,mailto:or#; images arehttps:only; an address with a user part (https://a@b) is removed. Sync prints what was removed. - Variables. Every value is HTML-escaped.
{{body}}and{{content}}keep their line breaks.{{library_url}}is the only variable that can be a link's address. - The footer. Your template renders inside its own box, and Gemmein adds its footer after it: your app's name and sending domain, and why the person received the email. The plain-text part is built from the result. None of these emails is marketing, so none has an unsubscribe line.
- Versions and limits. The cloud keeps each template's last 10 versions. Each app can save templates 60 times and preview them 120 times an hour (150 and 300 per account).
- Preview and test.
npx gemmein emails preview <name>renders your local file with sample data into a page you open.npx gemmein emails test <name>sends one test of the synced template to the address you are signed in with. Tests count toward Development sends.
Send response
notify() answers { sent, deduped?, recorded?, id, threadId, replyRail }.
deduped: true: the same key was sent before, and this is its answer again.recorded: false: the email was sent but not saved to the Inbox.replyRail: whether a reply can reach you now.- Two sends with the same key at once: the second answers
409 in_flight. - The caps answer
429withresetAt.
The full response, with every code, is in the SDK reference.
The Inbox
Every send appears in the dashboard's Inbox as a conversation. Once receiving is verified,
a person's reply lands in the same thread. Your answer goes out from support, so
the whole exchange stays in one place for both of you.
Step-by-step: see the Inbox and Domains dashboard guides.
What happens if…
Sending
For notify(). A relay's email_person rides the same caps. The
purchase email and Development's daily budget have their own sections:
The purchase email and
Development sends.
| If… | What happens | What you do |
|---|---|---|
| Your domain is not verified for email yet | 409 sender_domain_required. Nothing leaves, nothing is recorded and no cap is spent | Verify the domain on the Domains page. The same key then sends once |
| The person id is unknown, belongs to another environment, or the person is suspended | 404 not_a_customer. Nothing is sent | Send to a person id from the same environment as your key |
You retry with the same key | One email leaves. The retry answers deduped: true with the first send's id and spends no cap | Nothing |
Two people get the same key | Both are sent. A key is scoped to its person | Nothing |
Two sends with the same key arrive at once | The second answers 409 in_flight. Once the first finishes, the same key answers deduped: true | Retry after a moment |
| A person has had 5 emails today and you send another | 429 notify_capped with resetAt. Sends with kind: "account" are outside this cap | Wait for resetAt. Send sign-in, access and billing notices as kind: "account" |
| Your app has sent 200 emails this hour | The next answers 429 notify_capped with resetAt, whatever its kind | Wait for resetAt |
| Your mail provider fails or refuses your address | 502 send_failed. Nothing is sent from a Gemmein address instead, and the cap is handed back | Retry with the same key |
| Sends are switched off in Alerts | Every notify() answers 403 sends_disabled. Switching them back on restores sends, and Logs records each change | Switch sends on in Alerts |
| The subject, the text or the person is missing | 400 no_subject, no_text or no_person. A kind other than event or account is 400 invalid_kind | Fix the call |
| A subject contains line breaks | They are removed before sending, so a subject cannot add mail headers | Nothing |
| Receiving is not verified yet | The email is sent with replyRail: false. A reply has no path back to your Inbox | Verify receiving on the Domains page, or tell people where to write |
| A person is erased | Their sends are removed from your records. Other people's sends are untouched | Nothing |
| The person's address bounced for good, or they marked your mail as spam | 200 with sent: false, notSent: "address_bounced" or "address_complained" and a message. Nothing leaves and no cap is spent. A relay's email_person and the purchase email skip it the same way. Each skip is counted and on Logs, and the person's page says why | Don't retry. To mail them again, turn their email back on from their page |
| 10 of your customers' addresses have bounced or reported spam | Problems warns you, and again at 20, 30 and so on | Check how addresses are collected and what your emails say |
| Your domain is not verified and someone signs in | The sign-in code arrives from <App name> (via Gemmein), with the footer Sent by Gemmein on behalf of <App name>. From a verified domain it arrives from account, with Powered by Gemmein | Verify a domain for email |
| Your provider refuses your domain for a sign-in code | The code is sent again from Gemmein's shared address with the shared footer, so the person can still sign in. Sign-in codes are the only email with this fallback | Check the domain on the Domains page |
Templates
| If… | What happens | What you do |
|---|---|---|
| A template uses a variable its email does not have | Sync refuses that file with 400 unknown_variable and names the variables it has. The other files still sync | Use only the listed variables |
| A file's name is not one of the seven | It is refused with unknown_template and never synced | Rename it |
There is a sign-in.html | It is never synced. The sign-in email takes no HTML | Write sign-in.json |
sign-in.json has another key, a greeting or app name with a digit or an address, a colour that is not #rrggbb or is too light, or a logo that is not https: | 400 invalid_brand with the reason | Fix the value |
| The logo cannot be fetched when you save: not a PNG, JPEG, GIF or WebP, over 200 KB, a redirect, or an error | 400 logo_unavailable with the reason. Nothing is saved | Fix the address or the image, then save again |
| A template is over 100 KB | 400 template_too_large | Host images at https: addresses and trim the HTML |
A template holds a script, a form, an event handler, CSS maths or an http: image | It is saved with those removed, and sync lists what was removed | Nothing, or remove them from the file |
inbox-reply.html has no {{content}}, or more than one | 400 content_slot | Put it in once |
| An email has no template, or its template cannot be read or rendered | It is sent in Gemmein's plain design | Nothing |
| You save a template in Live | 409 templates_live_refused | Save it in Development; go-live or promote copies it into Live |
| Your app has saved templates 60 times, or previewed them 120 times, this hour | 429 templates_capped with resetAt | Preview locally with npx gemmein emails preview, which has no limit |
| You send a test | One email, subject [Test] …, to the address you are signed in with and no one else. It counts toward Development's 20 a day per app and 50 per account | Past that, wait for resetAt |
You delete a file from gemmein/emails/ | The cloud keeps the template. In npx gemmein dev it is removed at once | Run npx gemmein emails remove <name> |
In the Inbox
| If… | What happens | What you do |
|---|---|---|
| A person replies | The reply lands in the same conversation. A subject starting Re: or Fwd: joins the conversation it answers | Nothing |
| You open a conversation and do not reply | It is marked read and still needs a reply. Only your reply answers it | Reply |
| You close a conversation and they write again | It reopens, with the new message unread | Nothing |
| A person has waited 12 hours for your reply | Gemmein emails your owners and admins once for that wait. If you answer and they write again, a new wait starts | Reply. To stop these emails, switch the alert off under What we tell you in Alerts; the Inbox still shows the conversation waiting |
| A message's body cannot be fetched from the mail provider | The message shows without a body and says so | Choose Try again on the message |
| A person attaches a file | The message shows the file's name. The file itself is not kept | Ask them to send it another way if you need it |
| You start a conversation with someone who has not signed in | It is refused, and nothing is sent. The Inbox writes only to people who have signed in to your app | Ask them to sign in first |
| You start a conversation under a subject they are already waiting on | It is refused, and nothing is sent | Reply in that conversation |
| You have started 30 conversations in the last hour | The next new conversation is refused, and nothing is sent | Wait, then send it |
| Your domain is not verified for email | New conversation and Reply both say so, with a link to the Domains page, and send nothing. They never send from a Gemmein address | Verify the domain on the Domains page |
| Your provider refuses your address on a reply or a new conversation | Nothing is sent and nothing is recorded | Try again in a minute |
| A person is erased | Their conversation stays in your Inbox with their email address removed. Other people's conversations are untouched | Nothing |
| You reply to, or start a conversation with, an address that bounced or reported your mail as spam | It is refused with 409 address_suppressed, and nothing is sent | Reach them another way |
| One sender emails your Inbox more than 20 times in an hour, or more than 300 emails in an hour arrive from people who are not your customers | The rest are dropped as spam, not stored, and counted. Problems says how many. Your customers and people you already have a conversation with still get through, each still at most 20 an hour | Nothing. It is usually a mail loop or spam |