guide

Email

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 runs email_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() or email_person from a Gemmein address. Until your domain is verified, both are refused with 409 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 (for shop.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_failed and 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 from account.

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 (for shop.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:

WordLeaves from itA reply to it
accountsign-in codes; every notify(), either kind; a relay's email_person; the purchase emaillands in the Inbox labelled account
supportyour own answers from the Inboxthe address people write to
newsbroadcasts; notify(), email_person and the purchase email never use itlands 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 starting gemmein: are Gemmein's own and answer 400 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.

FileUsed forVariables
sign-in.jsonThe 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'sKeys: logoUrl brandColour backgroundColour appName greeting
purchase.htmlThe purchase email{{app_name}} {{product}} {{library_url}}
notify.htmlEvery notify() and relay email_person, unless a more specific template exists{{app_name}} {{subject}} {{body}}
notify-account.html, notify-event.htmlnotify() of that kindAs notify
email-person.htmlA relay's email_personAs notify
inbox-reply.htmlYour Inbox replies and new conversations, around your message{{app_name}} {{subject}} {{content}}
  • The sign-in brand. logoUrl is an https: 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 changed sign-in.json. brandColour and backgroundColour are #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. appName and greeting (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; @media rules stay when every selector names a class.
  • What Gemmein removes. Scripts, forms, frames, embeds, SVG, media, <link>, <meta>, <base>, comments, event handlers, CSS url(), @import, @font-face, calc() and other CSS maths, positioning, transforms and negative values. Links are https:, mailto: or #; images are https: 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 429 with resetAt.

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 happensWhat you do
Your domain is not verified for email yet409 sender_domain_required. Nothing leaves, nothing is recorded and no cap is spentVerify 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 suspended404 not_a_customer. Nothing is sentSend to a person id from the same environment as your key
You retry with the same keyOne email leaves. The retry answers deduped: true with the first send's id and spends no capNothing
Two people get the same keyBoth are sent. A key is scoped to its personNothing
Two sends with the same key arrive at onceThe second answers 409 in_flight. Once the first finishes, the same key answers deduped: trueRetry after a moment
A person has had 5 emails today and you send another429 notify_capped with resetAt. Sends with kind: "account" are outside this capWait for resetAt. Send sign-in, access and billing notices as kind: "account"
Your app has sent 200 emails this hourThe next answers 429 notify_capped with resetAt, whatever its kindWait for resetAt
Your mail provider fails or refuses your address502 send_failed. Nothing is sent from a Gemmein address instead, and the cap is handed backRetry with the same key
Sends are switched off in AlertsEvery notify() answers 403 sends_disabled. Switching them back on restores sends, and Logs records each changeSwitch sends on in Alerts
The subject, the text or the person is missing400 no_subject, no_text or no_person. A kind other than event or account is 400 invalid_kindFix the call
A subject contains line breaksThey are removed before sending, so a subject cannot add mail headersNothing
Receiving is not verified yetThe email is sent with replyRail: false. A reply has no path back to your InboxVerify receiving on the Domains page, or tell people where to write
A person is erasedTheir sends are removed from your records. Other people's sends are untouchedNothing
The person's address bounced for good, or they marked your mail as spam200 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 whyDon't retry. To mail them again, turn their email back on from their page
10 of your customers' addresses have bounced or reported spamProblems warns you, and again at 20, 30 and so onCheck how addresses are collected and what your emails say
Your domain is not verified and someone signs inThe 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 GemmeinVerify a domain for email
Your provider refuses your domain for a sign-in codeThe 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 fallbackCheck the domain on the Domains page

Templates

If…What happensWhat you do
A template uses a variable its email does not haveSync refuses that file with 400 unknown_variable and names the variables it has. The other files still syncUse only the listed variables
A file's name is not one of the sevenIt is refused with unknown_template and never syncedRename it
There is a sign-in.htmlIt is never synced. The sign-in email takes no HTMLWrite 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 reasonFix the value
The logo cannot be fetched when you save: not a PNG, JPEG, GIF or WebP, over 200 KB, a redirect, or an error400 logo_unavailable with the reason. Nothing is savedFix the address or the image, then save again
A template is over 100 KB400 template_too_largeHost images at https: addresses and trim the HTML
A template holds a script, a form, an event handler, CSS maths or an http: imageIt is saved with those removed, and sync lists what was removedNothing, or remove them from the file
inbox-reply.html has no {{content}}, or more than one400 content_slotPut it in once
An email has no template, or its template cannot be read or renderedIt is sent in Gemmein's plain designNothing
You save a template in Live409 templates_live_refusedSave it in Development; go-live or promote copies it into Live
Your app has saved templates 60 times, or previewed them 120 times, this hour429 templates_capped with resetAtPreview locally with npx gemmein emails preview, which has no limit
You send a testOne 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 accountPast that, wait for resetAt
You delete a file from gemmein/emails/The cloud keeps the template. In npx gemmein dev it is removed at onceRun npx gemmein emails remove <name>

In the Inbox

If…What happensWhat you do
A person repliesThe reply lands in the same conversation. A subject starting Re: or Fwd: joins the conversation it answersNothing
You open a conversation and do not replyIt is marked read and still needs a reply. Only your reply answers itReply
You close a conversation and they write againIt reopens, with the new message unreadNothing
A person has waited 12 hours for your replyGemmein emails your owners and admins once for that wait. If you answer and they write again, a new wait startsReply. 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 providerThe message shows without a body and says soChoose Try again on the message
A person attaches a fileThe message shows the file's name. The file itself is not keptAsk them to send it another way if you need it
You start a conversation with someone who has not signed inIt is refused, and nothing is sent. The Inbox writes only to people who have signed in to your appAsk them to sign in first
You start a conversation under a subject they are already waiting onIt is refused, and nothing is sentReply in that conversation
You have started 30 conversations in the last hourThe next new conversation is refused, and nothing is sentWait, then send it
Your domain is not verified for emailNew conversation and Reply both say so, with a link to the Domains page, and send nothing. They never send from a Gemmein addressVerify the domain on the Domains page
Your provider refuses your address on a reply or a new conversationNothing is sent and nothing is recordedTry again in a minute
A person is erasedTheir conversation stays in your Inbox with their email address removed. Other people's conversations are untouchedNothing
You reply to, or start a conversation with, an address that bounced or reported your mail as spamIt is refused with 409 address_suppressed, and nothing is sentReach 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 customersThe 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 hourNothing. It is usually a mail loop or spam