alepha@docs:~/framework/docs/guides/features$
cat 2-notifications.md | pretty
22 min read
Last commit:

#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.

typescript
1import { $notification } from "alepha/api/notifications";2import { $email } from "alepha/email";

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. The sender calls the provider directly, so the email:send:before / email:sent hooks only ever see $email callers. The same goes for SMS: a notification's sms channel calls the SmsProvider itself, and sms:send:before / sms:sent never fire for it. Delivery information for notifications comes from the receipt instead, not from a hook.

#1. Declaring a template

$notification needs AlephaApiNotifications registered, which brings alepha/api/jobs and alepha/api/parameters with it:

typescript
1import { AlephaApiNotifications } from "alepha/api/notifications";2 3alepha.with(AlephaApiNotifications);
typescript
 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
name the template's identity in receipts and the admin (default: property key)
description a line shown beside the template in the admin's template list
schema types variables for push() and every channel callback (compile time only)
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 channel blocks, resolved from the recipient's language

schema is a typing device, not a runtime check: the queued payload stores variables as a plain record, and nothing validates it against the template's schema at push or at send. Validate untrusted input before you push it.

translations maps a language to whole channel blocks ({ fr: { email: { subject, body } } }). The sender tries the exact language (fr-CA), then its base (fr), and falls back to the template's own block for a channel the translation does not declare.

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.

Channel blocks sit flat beside these options: email: { subject, body, text? }, sms: { message }, inbox: { ... } (section 13), plus any channel a plugin adds (section 12). One template may declare several; each push writes one job per declared channel.

#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. text is optional: without it the sender derives a plain-text part from the rendered HTML. 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.

typescript
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.

typescript
 1const escapeHtml = (value: string) => 2  value.replace( 3    /[&<>"']/g, 4    (c) => 5      ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ 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

typescript
1import { render } from "alepha/react/email";
tsx
 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

typescript
 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 lang, scheduledAt, delay, key, inline and attachments (section 8). pushMany takes scheduledAt, delay and a key function (contact) => string, and returns how many rows it pushed (one per contact per channel). It does not pre-filter suppressed contacts: the gate runs at send time (section 5).

inline: true sends now and waits. The promise resolves once the provider accepted the message and rejects if it refused; nothing retries it. It exists for codes that expire before a retry could land. A template with several channels blocks on each in turn, and a send addressed to somebody other than the caller must not use it: response time would reveal whether the account exists. See Background Jobs.

scheduledAt is bounded by the sweep, not the minute. A future date writes a scheduled row 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".

key is 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. The channel is appended to the key internally, so the email and the SMS of one template never dedupe against each other.

Without lang, push takes the language from the current request (the lang cookie, then Accept-Language). Outside a request there is no language to infer, and pushMany never guesses, 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. A row is keyed on the normalized contact and the channel, so a bounced email address does not silence SMS to the same person.

Your own product preferences live behind a seam:

typescript
1import { NotificationPreferenceProvider } from "alepha/api/notifications";

An implementation overrides allows({ contact, channel, template, category?, critical? }) and returns a boolean; the default allows everything. It 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. It is asked after the suppression check, so returning true never resurrects an address that bounced or complained.

#The other seam of the same shape

typescript
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. An implementation overrides resolve(contact): Promise<{ userId: string } | null>; it runs inside a job, with no request or current user to lean on, and is called twice per message (once in unavailable(), once in render()), deliberately uncached. 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.

The whole path, from one push() to one receipt per channel:

Flowchart diagramyesno, a sinkyesnonoyesa reasonundefinedacceptedthrowspush()one job per declared channeladdressable channel?suppressed?Ureceipt skipped: suppressedpreference provider allows?receipt skipped: declinedreceipt skipped: unavailablechannel render()channel send()receipt sentreceipt failed, job retries

Every skipped branch ends the job ok. A throw from render() or send() fails the attempt, and the job retries it (three retries, a 30-second timeout per attempt); only a send() throw writes a failed receipt.

#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_SECRET invalidates 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, deferred, bounced, complained, failed or rejected.

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 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=… (a root route, not under /api) and set BREVO_WEBHOOK_SECRET. An unset secret refuses every call.
  • SMTP: no async events exist. Bounces arrive as mail to the From address and are out of scope.

A custom transport feeds the same pipeline by emitting the notification:delivery:reported hook with { provider, eventId?, messageId, contact, channel, status, bounce?, smtpStatusCode?, raw, occurredAt }; the built-in webhooks do exactly that. eventId is the provider's own id for the event, and it is what makes a replayed event a no-op.

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 lists and lifts suppressions. An operator trusted with the first is not automatically trusted with the second. The admin API under /api/notifications has two more: admin:notification:read lists receipts and templates and renders previews, and admin:notification:delete removes receipts (the outbox row keeps its own clock).

#8. Attachments

An attachment is a reference resolved at send time, never bytes in the queued payload:

typescript
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, EMAIL_PORT (587), EMAIL_SECURE
Brevo alepha/email/brevo BREVO_API_KEY, EMAIL_FROM
Cloudflare alepha/email/cloudflare EMAIL_FROM, plus a SEND_EMAIL binding, or CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN off Workers
Local built in the default outside tests: writes .eml.json files under DATA_DIR/emails, which the devtools outbox reads
Memory built in the default under test

DATA_DIR defaults to node_modules/.alepha, which sits inside the deployed bundle. Local is also what a production app gets when it registers no real provider, so set DATA_DIR to a writable path, or better, register one.

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 the devtools (npx @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:

typescript
1import { MemoryEmailProvider } from "alepha/email";2 3const mail = alepha.inject(MemoryEmailProvider);4expect(mail.last?.subject).toBe("Welcome");5expect(mail.last?.text).toContain("Hello Alice");6expect(mail.wasSent("a@example.com", /Welcome/)).toBe(true);7expect(mail.wasSentMatching("a@example.com", /Hello Alice/)).toBe(true);

Records carry to, subject, body, text, replyTo, headers, attachments, messageId and sentAt, one record per recipient. wasSent(to, subject?) matches the subject; wasSentMatching(to, pattern) matches the HTML body or the text part.

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.

typescript
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:send:before 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 the default outside tests Writes files under DATA_DIR/sms for the outbox view
Memory the default under test, or provider: "memory" Keeps records in records / last
Your own provider: MyGatewayProvider on one $sms Extend SmsProvider and implement send()

To route every $sms that names no provider (and the notification sms channel) through your gateway, substitute it app-wide instead: alepha.with({ provide: SmsProvider, use: MyGatewayProvider }).

Both hooks fire around every send. sms:send:before carries an abort() that throws, which is where a global opt-out or a test-environment guard belongs:

typescript
1alepha.events.on("sms:send:before", ({ to, abort }) => {2  if (!isOptedIn(to)) {3    abort();4  }5});

⚠️ These hooks see $sms callers only. A $notification with an sms block goes through the SmsProvider directly, so a guard here does not stop it; the notification gate (section 5) is where its opt-outs live.

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

typescript
 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

typescript
 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

typescript
1export const AlephaPagerNotifications = $module({2  name: "alepha.notifications.pager",3  imports: [AlephaApiNotifications],4  services: [PagerChannel],5});

⚠️ services[], not merely export. Discovery is alepha.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:

typescript
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. preview calls the sender's render() directly, with no gate, so previewing a message whose recipient has since disappeared reaches a render() that resolves nobody, throws, and is reported as template-missing when 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.

typescript
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:

typescript
 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():

action route does
listInbox GET /api/inbox a page of messages, with scope, limit (max 100) and cursor
countInbox GET /api/inbox/count { unread }, optionally within one scope
markAllInboxRead POST /api/inbox/read marks every message read, optionally within one scope
markInboxRead POST /api/inbox/:id/read marks one message read

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 with skipReason: "unavailable".

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.