A mail in Plumix is declared once, by name, with the props it renders from. Code that sends it names it and hands it props; the mail renders itself in its recipient’s language and goes out through the site’s mailer. A site or a theme can restyle any mail by name without touching the code that sends it.
Overview
Section titled “Overview”Three pieces make up the mail system, and each has one owner:
- The declaration.
defineMail<Props>(name, { subject, text, html, preview })says what a mail looks like, rendered fromProps. PassPropsexplicitly or letpreviewinfer it. Core declares its own mails,magicLink,emailChangeandmailTest. A plugin lists its mails in its descriptor’smailsfield, so every mail is known when the site boots. - The registry.
MailRegistrymaps each mail’s name to its props, the same wayBlockTypeRegistrymaps a block’s name to its attrs.ctx.mail.sendand every override are typed against it, so an unknown name or the wrong props fail to compile. - The transport. The
mailerslot inplumix.config.tsdelivers what a mail renders: anEmailMessagewithto,subject,textand an optionalhtml. A mailer never sees a mail’s name or props, so any provider fits.
ctx.mail.send(name, props, { to }) renders the mail and hands it to the mailer. With no mailer configured it throws MailerNotConfigured instead (except under plumix dev, which catches the mail), so a feature whose mail is optional catches that and carries on. The comments plugin does that, so a site without a mailer still takes comments.
Two owners declaring the same name stop the boot, and the error names both. So does an override of a name nobody declared.
Quickstart
Section titled “Quickstart”Declare the name and its props, define the mail, list it in the plugin’s mails, and send it from anywhere that holds a request context:
import type { AppContext } from "plumix";import { defineMail, definePlugin } from "plumix/plugin";
declare module "plumix" { interface MailRegistry { welcome: { readonly name: string }; }}
const GREETING = { id: "acme.mail.welcome.greeting", message: "Welcome, {name}!",};
export const welcomeMail = defineMail<{ readonly name: string }>("welcome", { subject: (props, ctx) => ctx.t(GREETING, { name: props.name }), text: (props, ctx) => `${ctx.t(GREETING, { name: props.name })}\n\n${ctx.baseUrl}`, preview: { name: "Ann" },});
export const welcome = definePlugin("welcome", { mails: [welcomeMail], setup: () => undefined,});
export async function greet(ctx: AppContext, email: string): Promise<void> { await ctx.mail.send("welcome", { name: "Ann" }, { to: email });}preview is required. It is the sample a mail renders with when there is no real send behind it.
Declaring a mail
Section titled “Declaring a mail”Each render function takes the mail’s props and a render context, and returns a string:
| Render function | Required | What it returns |
|---|---|---|
subject |
yes | The subject line. |
text |
yes | The plain-text body. Every mail has one. |
html |
no | An HTML body. Without one the mail is text alone. |
The render context carries the locale the mail renders in, the siteName, the site’s baseUrl (the public root with its base path and a trailing slash) and t, which translates a descriptor into that locale. The site name is auth.magicLink.siteName when the site configures one, and the origin’s host otherwise.
An HTML body is a string, so escape everything it did not write itself. A commenter’s name, a comment body and an address are all input. escapeHtml from plumix/support covers element text; an attribute value also needs its quotes escaped.
Sending a mail
Section titled “Sending a mail”to is an address or a user. The locale the mail renders in is decided per recipient:
- The recipient’s own
meta.locale, when the recipient is a user, or the address belongs to one, and the site enables that locale. - Otherwise the request’s locale, which a sign-in form’s
?lang=choice sets. - Otherwise the site’s default locale.
Pass a user when the address is not yet theirs. Core’s emailChange goes to the address the user is moving to, which belongs to nobody, so it passes { email: newEmail, meta: user.meta } and the user’s own locale still applies.
send throws whatever the mailer throws. Whether a failed send should fail the request is the caller’s decision. Core’s magic-link request swallows and logs it, because a response that differed would reveal whether the address is registered.
import type { AppContext } from "plumix";import { MailerNotConfigured } from "plumix/plugin";
declare module "plumix" { interface MailRegistry { welcome: { readonly name: string }; }}
export async function greetIfPossible( ctx: AppContext, email: string,): Promise<void> { try { await ctx.mail.send("welcome", { name: "Ann" }, { to: email }); } catch (error) { if (error instanceof MailerNotConfigured) return; throw error; }}Overriding a mail
Section titled “Overriding a mail”A site or a theme replaces any of a mail’s render functions by name, typed against that mail’s props. Each render function comes from the first of these that sets it:
- The site’s
mail.overridesinplumix.config.ts. - The theme’s
mailfield. - The declaring owner’s own.
A site override of only subject keeps the theme’s or the owner’s text and html. A site sets its overrides in the mail slot:
import type { MailConfig } from "plumix";
export const mail: MailConfig = { overrides: { magicLink: { subject: (_props, ctx) => `Your ${ctx.siteName} sign-in link`, }, },};A theme gives the mails it did not declare its own look:
import type { TemplateData } from "plumix/theme";import { defineTemplate, defineTheme, fallback } from "plumix/theme";
const index = defineTemplate<TemplateData>({ render: () => null });
export const theme = defineTheme({ templates: [fallback(index)], mail: { magicLink: { html: (props) => `<p><a href="${props.url}">Sign in</a></p>`, }, },});Core’s mails take these props. Both URLs are the single-use links core generates, and ttlSeconds is how long each stays valid. mailTest is what plumix mail test sends: sentAt is an ISO 8601 timestamp, and origin is "local" from the command line or "remote" through the deployed site.
| Props | |
|---|---|
magicLink |
{ url, ttlSeconds } |
emailChange |
{ url, oldEmail, newEmail, ttlSeconds } |
mailTest |
{ siteName, sentAt, origin } |
Localizing a mail
Section titled “Localizing a mail”ctx.t(descriptor, values) resolves a descriptor against core’s mail catalog and every installed plugin’s catalog, in the locale the mail renders in, and falls back to the descriptor’s English message. Only {name} placeholders are filled, so a count that needs a plural takes one message per form.
A plugin’s mail strings go in the same catalogs as the rest of its strings, through its i18n slot. Plugin i18n covers the catalogs; write the descriptors as object literals, since a mail renders on the server and no Babel macro pass reads it.
Staging
Section titled “Staging”A staging site that runs on a copy of production data would mail real people. alwaysTo(address, transport) wraps the site’s mailer so every mail goes to address instead, whoever it was for.
import type { Mailer } from "plumix";import { alwaysTo } from "plumix";
declare module "plumix" { interface PlumixEnv { readonly STAGING_INBOX: string; readonly RESEND_API_KEY: string; }}
declare function resend(apiKey: string): Mailer;
export const mailer = alwaysTo( (env) => env.STAGING_INBOX, (env) => resend(env.RESEND_API_KEY),);Both arguments take an EnvInput, so the inbox and the transport’s key can both come from the deploy’s secrets. Pass plain values when they are not secret: alwaysTo("qa@example.com", consoleMailer()).
An EmailMessage carries no headers, so the original recipient goes in the body. The text starts with Originally to: <address>, and an HTML body starts with the same line as a small banner. Subject and the rest of the body are unchanged.
The address is checked when the mailer is resolved, which happens as a request’s context is built. An empty or malformed one throws a ConfigError that names it, so a site with a missing STAGING_INBOX fails its requests instead of quietly mailing nobody.
Testing the mailer
Section titled “Testing the mailer”plumix mail test <to> sends mailTest through the site’s mailer and prints the transport’s own error when it fails. --remote --url <origin> sends it through the deployed site, with its production secrets, using an API token. The CLI reference covers both and where the token comes from. Core ships no rate limiter for the route --remote calls, as for the rest of the REST API; to throttle it, apply the edge rule Rate limiting describes.
plumix mail test ann@example.comPreviewing mail in development
Section titled “Previewing mail in development”plumix dev never drops a mail. With no mailer configured it catches each one instead: it logs the message the way consoleMailer() does and keeps it, so a site can try email sign-in before it has a provider. With a mailer configured, the mail goes out through it as usual. Either way, the request that sent a mail records what it rendered, and the debug bar’s Mail panel shows it with HTML and text tabs.
The gallery at /_plumix/debug/mail shows every declared mail without sending it. It groups them by owner, core first and then each plugin, and renders each one from its preview props with the site’s and the theme’s overrides applied. Each mail shows its subject above the message, and an HTML tab and a text tab below it. The locale links at the top re-render every mail in one of the site’s locales. Below the declared mails, “Sent in dev” lists the mail the dev server caught, newest first, in the same viewer.
/_plumix/debug/mail/sent returns the caught mail as JSON, newest first: each entry’s name, props, to, locale, subject, text and html. A DELETE with the x-plumix-request: 1 header empties it. The dev server keeps as many as the dev.history caps allow, the same ones that bound the request history: 10 mails and 2 MB by default, with each string cut at 8,192 characters. Configuration covers dev.history.
None of this exists in a build. The gallery, the JSON route, the Mail panel and the catching itself sit behind the development gate, and like every development surface they answer only on loopback. Dev Server covers the gate.
Related
Section titled “Related”- Configuration — the
mailerslot every mail goes out through, and themailslot a site overrides mails in. - Plugins — the descriptor whose
mailsfield declares a plugin’s mails. - Themes — the descriptor whose
mailfield overrides them. - Type Augmentation —
MailRegistryamong the other registries you extend withdeclare module "plumix". - Dev Server — the development gate the gallery and the Mail panel sit behind.
Next steps
Section titled “Next steps”- Configure a real
mailerbefore you deploy; Secrets walks through one. - Give core’s
magicLinkmail your theme’s HTML with the theme’smailfield.