Transactional email
One template set for every outbound mail — multipart, canonical links, imprint footer.
Every mail the product sends — organization invitation, e-mail verification,
e-mail change confirmation, password reset, two-factor code, feedback
notification, and account/organization deletion confirmation — renders from one
template set into a multipart message: a plain-text part that reads complete on
its own with bare URLs, and a minimal HTML part carrying the same content. All
links point at the canonical public app URL (BETTER_AUTH_URL); the request
host is never used, because behind the ingress that host is internal. Who
receives which mail follows from organization access:
invitations are the only way into an organization, so these messages are the
product's first impression — and its top spam-filter risk.
What every message carries
- Both parts, always — text and HTML (
multipart/alternative). - A subject that is never all-caps.
- Links only to the canonical app origin — no shorteners, no third-party hosts, no images (the logo is text).
- The imprint footer in both parts.
- The same sender on every mail, with an optional reply-to override per message (the feedback notification uses it so a reply reaches a person).
| Variable | Required | Carries |
|---|---|---|
MAIL_FROM | yes | The From header on every mail, e.g. Devboxes <no-reply@devboxes.ai> |
MAIL_FEEDBACK_TO | yes | The inbox that receives feedback submitted from the dashboard |
MAIL_IMPRINT | yes | The legal name and postal address rendered into every footer (German imprint requirement) |
MAIL_FROM replaced SMTP_FROM with no fallback. A deployment boots only with MAIL_FROM,
MAIL_FEEDBACK_TO, and MAIL_IMPRINT set; production must carry the operator's real legal name
and postal address in MAIL_IMPRINT.
How to test it
| Suite | Proves |
|---|---|
pnpm vp run @firops/api#test — templates.test.ts | Every template renders subject, text, and HTML from fixture data; the hygiene contract above holds for each one; interpolated names and messages cannot inject markup into the HTML part |
pnpm vp run @firops/api#test — invitation-email.integration.test.ts | Inviting a member through the real auth flow sends exactly one message with the configured sender, the invitee as recipient, and a working accept URL in both parts |
pnpm vp run @firops/api#test — data-rights integration suite | Completed account and organization deletions send confirmation to the captured address after storage cleanup |
Manual smoke: pnpm vp run @firops/api#email:preview renders every template
with fixture data to HTML and text files and prints the output directory. The
dev stack's compose Mailpit receives real sends for checking the wire shape.
Decisions
- Plain template literals, no mail-template engine — a handful of templates does not earn an MJML/react-email dependency; the earlier react-email renderer was replaced so subject, text, and HTML render together from one readable module.
- The text part is written by hand, not derived from the HTML — it must read complete on its own in a text-only client, with bare URLs a reader can copy.
- Links derive from the canonical public app URL, never the request host — mail generated by an API pod behind the ingress must not leak an internal host into a link.
MAIL_FROM,MAIL_FEEDBACK_TO, andMAIL_IMPRINTare required with no compatibility alias — a deployment that cannot route complete mail fails at boot instead of sending from or to an implicit address.- Organization teams stay disabled — the teams switch was on while the database schema had no team tables, which made every invite fail with a server error; nothing used teams, so the switch went away rather than the schema growing. Re-enabling teams starts with the schema.
- Deletion confirmations use the shared template at the completed boundary — account and organization deletion capture the recipient before erasure, complete storage cleanup, and then send the existing multipart confirmation.
Next: Repository connections — how code gets in.