#Notifications and Email
Two layers, and picking the wrong one is the most common mistake.
$email sends a message you have already written. $notification declares a
template and pushes it through a durable outbox, with retries, an audit
trail, a suppression list and delivery receipts.
1import { $email } from "alepha/email";2import { $notification } from "alepha/api/notifications";
Use $email when the app itself decides the words at the moment of sending
(commerce's order mail does this). Use $notification for anything a
recipient might later want to stop receiving, or that you will one day be
asked "did it arrive?" about.
Notification mail does not pass through
email:sending/email:senthooks only ever see
#1. Declaring a template
1import { z } from "alepha"; 2import { $notification } from "alepha/api/notifications"; 3 4class Notifications { 5 welcome = $notification({ 6 category: "onboarding", 7 schema: z.object({ name: z.text() }), 8 email: { 9 subject: "Welcome",10 body: (vars) => `<h1>Hello ${vars.name}</h1>`,11 },12 });13}
| option | what it changes at send time |
|---|---|
schema |
validates variables on every push |
category |
what an unsubscribe link switches off |
critical |
bypasses unsubscribed, carries no unsubscribe header |
sensitive |
withholds variables, subject and body from the admin view and the receipt |
translations |
per-language subject / body, resolved from the recipient's language |
critical is for messages the recipient needs in order to use their account:
a password reset, a sign-in code. It is not a way to make marketing arrive.
#2. Writing the body, and its three sharp edges
A body is string | ((variables) => string | Promise<string>). There is
deliberately no template engine: no {{var}} syntax, no database-stored
templates, no live editing. A custom syntax is unlearnable and untypable, and
a template edited in an admin UI is unversioned state that no longer matches
the code deployed against it.
subject and text take the same union, and see the same variables plus
unsubscribeUrl. Build the subject whenever the message is about something
in particular: the subject line is what a phone shows in its notification, so
a sign-in code that is only in the body is a code the recipient has to open a
mail client to read.
1email: {2 subject: (vars) => `Your code is ${vars.code}`,3 body: (vars) => `<p>${vars.code}</p>`,4}
A built subject can carry a name or an amount, which is what sensitive
above is for: it keeps the subject off the delivery receipt.
Three things the framework does not do for you:
Nothing escapes for you. Interpolating a user-supplied value into HTML is an injection. The framework's own templates only interpolate values it generated itself (a code, a URL, the recipient's own address), so they are safe; the first app that puts a display name in a body and does not escape it is not.
1const escapeHtml = (value: string) => 2 value.replace( 3 /[&<>"']/g, 4 (c) => 5 ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ 6 c 7 ]!, 8 ); 9 10body: (vars) => `<p>${escapeHtml(vars.inviterName)} invited you</p>`;
Bodies are fragments, not documents. Every provider is handed body as
the HTML part, with no <!DOCTYPE>, no <html>, no charset. Clients tolerate
it. An app that wants an Outlook-safe shell builds one itself.
RTL is the app's job. A bare fragment in Arabic or Hebrew renders
left-to-right. If you ship a shell, wrap it in dir="rtl" for RTL recipients.
#3. React templates
1import { render } from "alepha/react/email";
1const WelcomeEmail = (props: { name: string }) => ( 2 <html lang="en"> 3 <body> 4 <h1>Hello {props.name}</h1> 5 </body> 6 </html> 7); 8 9welcome = $notification({10 schema: z.object({ name: z.text() }),11 email: { subject: "Welcome", body: render(WelcomeEmail) },12});
render() takes the component, not an element: an element would have to
be built before any variables exist.
@react-email/components (Outlook-safe tables, <Tailwind>, <Button> with
VML fallbacks) works through this unchanged. Install it in your app;
the framework does not depend on it, and deliberately does not use
@react-email/render either, which ships prettier in its edge build.
A template gets props, not the container: no useI18n() or useInject()
inside an email component. Use translations for language.
#4. Sending
1await this.welcome.push({ 2 contact: "a@example.com", 3 variables: { name: "Alice" }, 4}); 5 6await this.reminder.pushMany({ 7 contacts: roster.map((p) => ({ 8 contact: p.email, 9 variables: { name: p.name },10 })),11});
push also takes scheduledAt, delay and key.
scheduledAtis bounded by the sweep, not the minute. A future date writes ascheduledrow that the sweep promotes on its next tick (every 15 minutes by default), so "remind at 09:00" means "at the first tick at or after 09:00".
keyis concurrency dedup, not idempotence over time. The job layer clears the key on both terminal states, success and failure alike. A repeated key returns the first execution only while it is still pending, running or scheduled; once the send has completed, the same key pushes a second row and sends a second message. For "at most one reminder per day", keep your own marker on the subject row.
Outside a request there is no language to infer, so give each contact its own
lang if it matters.
#5. Suppression and preferences
One table, three reasons, two strengths:
| reason | blocks |
|---|---|
unsubscribed |
non-critical mail, in the matching category |
bounced |
everything, critical included |
complained |
everything, critical included |
A password reset still reaches someone who unsubscribed from reminders. Nothing reaches an address that hard bounced.
Your own product preferences live behind a seam:
1import { NotificationPreferenceProvider } from "alepha/api/notifications";
An implementation must consider both axes: a channel switched off entirely, and one category refused. "No email at all" and "no email about this" are different answers, and an unsubscribe link expresses the second.
#The other seam of the same shape
1import { NotificationInboxRecipientProvider } from "alepha/api/notifications";
push({ contact }) hands one string to every channel, and the inbox
channel needs a person rather than an address. This is how an app answers
"who is this?" without the notifications module learning what a user is.
The default returns null, so an app that has not implemented it declines rather than crashes. The contact arrives normalized, trimmed and lower-cased; normalize on the way in too, because the column you look it up against is probably not normalized either.
An app that implements one of these two usually wants the other: this one answers who the contact is, the one above whether they accept the message.
The gate runs at send time, never at push time: a suppression can land in between, and the send-time answer is the authoritative one. A refused send returns without throwing, so the job completes and no retry fights the gate.
#6. Unsubscribe
Non-critical mail carries List-Unsubscribe and List-Unsubscribe-Post
automatically, and the body can render the same URL from an unsubscribeUrl
variable. Both need PUBLIC_URL; without it the headers are omitted rather
than made relative.
The token is a stateless HMAC over (contact, channel, category, template)
with no expiry, because a link in a six-month-old mail must still work.
⚠️ Rotating
APP_SECRETinvalidates every outstanding unsubscribe link, in mail already delivered, with no fallback and no way to tell the recipients. That is the price of not having a token table. An app that rotates secrets on a schedule needs to know this before it does.
#7. Delivery receipts
Every send writes one receipt, on all three outcomes: sent, skipped and
failed (the provider threw). Provider events then update it to delivered,
bounced, complained and so on.
A skipped receipt carries a skipReason, and there are three:
skipReason |
who decided |
|---|---|
suppressed |
the suppression list: bounced, complained, unsubscribed |
declined |
the app's preference provider |
unavailable |
the channel, before anything was rendered |
The first two are the gate, which runs before the channel is asked anything.
The third is the channel's own answer, and it exists because the alternative
is worse: a channel handed a contact it cannot deliver to used to have no
option but to throw from render(), which the sender calls outside its
attempt wrapper - so it wrote no receipt at all and the job retried an address
that would never resolve, burning every attempt down to a terminal failure.
See {@link NotificationChannel.unavailable} in section 12.
Three retention clocks. The job outbox keeps retentionDays (7 by
default), successes and failures alike, enforced by the jobs module's trim,
which reads the setting at every tick; receipts keep receiptRetentionDays
(90), because a complaint can arrive weeks after the send; and read inbox
messages keep inboxRetentionDays (90), because a read message is one the
reader has already dealt with. The admin detail view joins the outbox row when it still
exists and renders correctly when it does not.
storeRenderedBody is off by default: 90 days of full HTML for every
notification is real bytes, and a fan-out over a roster multiplies it. The
subject is always kept, except on a sensitive template.
To ingest bounces and complaints:
- Cloudflare: create an Email Sending event subscription (per sending
domain) onto a queue, and set
CLOUDFLARE_EMAIL_EVENTS_QUEUE. No producer binding is needed; an app running jobs in direct mode still gets its events. - Brevo: point a transactional webhook at
/notifications/webhooks/brevo?secret=…and setBREVO_WEBHOOK_SECRET. An unset secret refuses every call. - SMTP: no async events exist. Bounces arrive as mail to the
Fromaddress and are out of scope.
Only a hard bounce suppresses. Cloudflare marks every message.bounced
terminal, including ones that merely exhausted temporary retries; the split
is payload.bounce.type.
Two admin permissions, deliberately not one: admin:notification:send
resends a message, admin:notification:write lifts a suppression. An operator
trusted with the first is not automatically trusted with the second.
#8. Attachments
An attachment is a reference resolved at send time, never bytes in the queued payload:
1await this.invoice.push({2 contact: customer.email,3 variables: {},4 attachments: [{ storage: "invoices", fileId: stored.id }],5});
The payload is a JSON column that is logged, retried and kept; a base64'd PDF
in there is a row nobody can read and a queue message over Cloudflare's 128 KB
limit. Count and total-size caps live on the api.notifications
parameter, and a missing object fails the send rather than delivering an
invoice email with no invoice.
Note that an attachment on bulk mail is a stronger spam signal than the same mail without one.
#9. Providers
| provider | module | needs |
|---|---|---|
| SMTP | alepha/email/smtp |
EMAIL_HOST, EMAIL_FROM, optionally EMAIL_USER / EMAIL_PASS |
| Brevo | alepha/email/brevo |
BREVO_API_KEY, EMAIL_FROM |
| Cloudflare | alepha/email/cloudflare |
a SEND_EMAIL binding, or CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN off Workers |
| Local | built in | writes .eml.json files under DATA_DIR, which is what the devtools outbox reads |
| Memory | built in | the default under test |
SMTP does not run on Cloudflare Workers: nodemailer needs node:net and
node:tls. A Cloudflare build resolves alepha/email/smtp to an entry
without nodemailer, so it costs the Worker nothing to import, and registering
AlephaEmailSmtp there throws, naming the Cloudflare provider instead of
dropping mail in silence.
Cloudflare takes a to: string[] as one message (50 recipients max) and
returns one id; Local and Memory fan out to one record per recipient. The
notification layer always pushes one job per contact, so receipts line up
one-to-one.
headers cannot set From, To, Subject, Cc, Bcc, Reply-To or
Content-Type: those are refused, not stripped, because a caller-controlled
header map is otherwise a spoofing surface. Use the replyTo option.
#10. Previewing and testing
In development the Local provider writes every message to DATA_DIR, and
@alepha/devtools renders them in its Outbox view, emails and SMS
together. That is the preview: send the thing and look at it.
In tests, use MemoryEmailProvider:
1const mail = alepha.inject(MemoryEmailProvider);2expect(mail.last?.subject).toBe("Welcome");3expect(mail.last?.text).toContain("Hello Alice");
Records carry to, subject, body, text, replyTo, headers,
attachments and messageId.
travel()fires every cron in the container, including the notification purge. Assert end state (which rows exist), never call counts.
#11. SMS, without the notification layer
$notification is a template with preferences, suppression, receipts and an
outbox behind it. $sms is the layer below: a named channel that hands a
pre-rendered string to whichever provider is configured.
1import { $sms } from "alepha/sms";2 3class Verification {4 code = $sms({ name: "verification-code" });5 6 async sendCode(to: string, code: string) {7 await this.code.send({ to, message: `Your code: ${code}` });8 }9}
send() takes to (one number or a list) and message. There is no template
rendering and no subject: build the string yourself.
Declare a channel per purpose rather than one per app. The name is what appears
in the sms:sending and sms:sent hooks and in the devtools outbox, so
verification-code and delivery-update stay distinguishable where a single
sms channel would not.
| Provider | Selected by | Does |
|---|---|---|
| Local | development default | Writes files under DATA_DIR for the outbox view |
| Memory | $sms({ provider: "memory" }), tests |
Keeps records in records / last |
| Your own | provider: MyGatewayProvider |
Extend SmsProvider and implement send() |
Both hooks fire around every send. sms:sending carries an abort() that
throws, which is where a global opt-out or a test-environment guard belongs:
1alepha.events.on("sms:sending", ({ to, abort }) => {2 if (!isOptedIn(to)) {3 abort();4 }5});
Under test, MemorySmsProvider exposes records and last, the same shape as
MemoryEmailProvider.
Use $sms directly for a transactional string a person is waiting on right now
(a login code). Use $notification when the message is something a user could
reasonably want to turn off, because that is what the preference and suppression
machinery exists for.
#12. Writing a channel
email, sms and inbox are not special. Each is a NotificationChannel
service registered in the notifications module, and a package outside the
framework adds its own the same way. @alepha/discord is the worked example:
it posts an ops message into a Discord room through an incoming webhook, and
it is about two hundred lines.
The three built-ins are not a closed set and none of them takes a shortcut the
contract does not offer: inbox in particular ships in the box and is written
exactly the way the rest of this section describes, declaration merge
included. Section 13 is what it does with that.
A channel is three declarations, and missing one of them is the usual mistake.
#The class
1import { 2 NotificationChannel, 3 type NotificationRendered, 4 type NotificationRenderInput, 5} from "alepha/api/notifications"; 6 7interface PagerMessage { 8 to?: string; 9 message: (variables: Record<string, unknown>) => string | Promise<string>;10}11 12export class PagerChannel extends NotificationChannel<PagerMessage> {13 public readonly channel = "pager";14 public readonly addressable = false;15 16 public async render(input: NotificationRenderInput<PagerMessage>) {17 const to = input.message.to ?? "ops";18 return {19 recipient: `pager:${to}`,20 body: await input.message.message(input.variables),21 };22 }23 24 public async send(rendered: NotificationRendered) {25 // POST it somewhere. Throwing is how a failure is reported: the sender26 // writes a `failed` receipt and the job retries.27 return { messageId: undefined };28 }29}
render and send are split because the admin preview renders without
sending. There is no target parameter: an addressable channel's recipient is
on the payload, and a sink's destination comes from its own option block.
#The two declaration merges
1declare module "alepha/api/notifications" { 2 interface NotificationChannels<V> { 3 pager?: { 4 to?: string; 5 message: (variables: V) => string | Promise<string>; 6 }; 7 } 8 9 interface NotificationSinkChannels {10 pager: true;11 }12}
The first is what makes $notification({ pager }) typecheck, and the generic
V is what lets message see the template's own variables. The second says
this channel is a sink, and it is the one people forget: without it,
push() on a pager-only template still demands a contact for a message
going to a room.
Put both in the package's single entry point. An augmentation applies only where it is in scope, so a subpath export makes "import the module, get the types" quietly untrue.
#The registration
1export const AlephaPagerNotifications = $module({2 name: "alepha.notifications.pager",3 imports: [AlephaApiNotifications],4 services: [PagerChannel],5});
⚠️
services[], not merelyexport. Discovery isalepha.services(NotificationChannel), which filters instantiated services, so a channel nobody injects is invisible to the registry. The symptom is confusing: the framework's boot check refuses a template that declares your own channel.
#Addressable, or a sink
addressable: true |
addressable: false |
|
|---|---|---|
| goes to | a person | a place named in the template |
contact on push() |
required | optional |
| suppression list | checked | skipped |
| preference provider | asked | skipped |
| unsubscribe token and headers | minted | none |
recipient |
the contact | <channel>:<destination> |
The gate is skipped for a sink deliberately. A suppression row spelled
discord:alerts would be indelible: nobody can click an unsubscribe link for
a chatroom, so one stray bounce would silence an ops alert for good.
#Three members that look optional and are not
render() must return a recipient. It is written straight into the
delivery receipt's contact column, which is NOT NULL, and it is the whole
reason the sender never branches on the kind of channel.
providerName() should be overridden by a channel that is an adapter over
a swappable transport, so a receipt names the transport rather than the
adapter. The default is the channel's own class name, which is right for a
channel that IS its transport. NotificationEmailChannel overrides it so a
receipt keeps saying BrevoEmailProvider; nothing asserts on that field by
default, so getting it wrong degrades the audit trail silently.
unavailable() is how a channel says it cannot deliver this message,
before anything is rendered:
1public override async unavailable(payload: NotificationPayload) {2 const user = await this.recipients.resolve(payload.contact ?? "");3 return user ? undefined : { reason: "unresolved-recipient", recipient: payload.contact! };4}
The sender consults it after the gate and before render(), for every
channel including sinks - a sink can be misconfigured too. Returning a reason
writes one skipped receipt with skipReason: "unavailable" and ends the job
ok. The default is undefined, so a channel that never overrides it is
unaffected.
recipient comes back beside the reason because the receipt's contact
column is NOT NULL, and a channel that cannot resolve a user still knows the
string it was handed.
⚠️ The admin preview does not consult it.
previewcalls the sender'srender()directly, with no gate, so previewing a message whose recipient has since disappeared reaches arender()that resolves nobody, throws, and is reported astemplate-missingwhen the template is fine. Deliberate: a more precise label on a screen an operator reaches once a quarter is not worth a schema change and a UI branch.
#Configuration belongs in an atom
A sink's destination is usually a credential. Keep it in an $atom, never in
template code: the template names a destination, and the channel resolves it.
1alepha.set(discordOptions, {2 destinations: {3 alerts: { webhook: process.env.DISCORD_ALERTS!, default: true },4 releases: { webhook: process.env.DISCORD_RELEASES! },5 },6});
A receipt then records discord:releases, and the webhook reaches neither the
outbox row nor the admin preview. The preview cannot leak it by construction:
a channel carries whatever it likes in its own private rendered type, and the
controller returns only NotificationRendered's base fields.
to should be a literal string, never a function. Dynamic routing reduces a
boot check to "the map is not empty" and puts a typo'd destination back at
3am. A template that needs two rooms declares two channel blocks.
#Fail at boot, not at 3am
The framework refuses to boot when a template declares a channel nothing
provides, naming the template, the channel and the module to import. A plugin
should add its own half in a $hook({ on: "start" }): that every destination
exists, that at most one is flagged default, and that every to a template
names is configured.
#13. The inbox channel
The third built-in delivers to a table rather than to a transport: one row
per message in notification_inbox, which a person reads, clicks and
dismisses. It is addressable, so it keeps the whole gate.
A template declares it beside its other channels:
1import { z } from "alepha"; 2import { $notification } from "alepha/api/notifications"; 3 4class Templates { 5 readonly mentioned = $notification({ 6 name: "app:mentioned", 7 category: "mentions", 8 schema: z.object({ who: z.text(), where: z.text(), project: z.text() }), 9 inbox: {10 title: (v) => `${v.who} mentioned you`,11 body: (v) => v.where,12 href: (v) => `/quests/${v.where}`,13 scope: (v) => `project:${v.project}`,14 scopeLabel: () => "Alepha",15 },16 });17}
href is required. A message that cannot be clicked makes the reader hunt for
what it is about.
#Two opaque strings, and why there are two
scope is app-owned. The framework stores it, compares it for
equality, and never parses it - the same discipline an opaque environment
slug keeps. That is what lets one project-agnostic table serve a
project-filtered URL without the module learning what a project is.
scopeLabel is the price of that opacity: nothing downstream can turn
project:65 into a name. The framework must not parse it, and a shared UI
component cannot resolve it either, because an inbox is cross-scope by
nature. So the pusher writes the readable string too, and it is frozen at
send time - a renamed project keeps the name it had when it pinged you.
#Reading it back
NotificationInboxController serves four actions behind a bare $secure():
listInbox, countInbox, markInboxRead and markAllInboxRead.
There is no user id in any of their signatures. Every row is filtered by the session's own, which is what makes the surface safe to leave un-permissioned, and a row belonging to somebody else is a 404 rather than a 403, so the endpoint never confirms an id exists.
Paging is an opaque cursor over (createdAt, id), not an offset: the list is
append-heavy, so an offset page shifts under the reader every time a message
arrives. A malformed cursor is a 400, never a silent page one.
#What an app owns
Two things, and neither is optional.
Resolving the recipient. Substitute NotificationInboxRecipientProvider
(section 5). The default resolves nobody, so without it every inbox send is a
skipped receipt.
Deleting an account's messages. notification_inbox.userId is a bare
uuid with no foreign key - this module imports nothing from
alepha/api/users, so there is no table to point at and nothing cascades.
Call NotificationInboxService.deleteForUser(userId) from the app's own
user:delete:before handler, after whatever refusal that handler already
performs: a separate handler can run first and empty the inbox of an account
whose deletion is then refused.
The hourly purge covers expiry, and only ever removes messages that have been read. An unread message is kept at any age: it waited for you, which is the feature.
#See also
- Authentication explains why a
password-reset setting is refused at boot without
features.notifications. - Background Jobs is the outbox underneath all of this.