notification
All outbound email, behind one narrow interface. Any module delivers mail through iface.NotificationSender without knowing anything about transport, rendering, preferences, or suppressions. The primary consumer today is auth — verification and password-reset messages.
The interface was designed multi-channel from the start (SMS, push, webhook), but only email is implemented.
It boots in noop mode
A fresh install does not send mail. The default provider is noop, which renders the message and logs it to the backend's stdout instead of dialing an SMTP server — which is what you want in dev and CI. Crucially the module still reports IsConfigured() = true under noop, so consumers can make send calls without failing.
To send real mail, switch email.provider to smtp at /admin/modules/notification and fill in the connection settings. See Notifications & SMTP for the operator walkthrough.
Config
Fifteen flat keys — seeded from env vars on first boot and owned by the ConfigService afterwards — plus one record list. /admin/modules/notification renders them as a four-group rail:
| Group | Keys |
|---|---|
| Delivery | email.provider plus the five email.smtp.* fields (the default transport, used while no sender profile routes a category) and the three RFC 8058 one-click unsubscribe fields — public_api_base_url, unsubscribe_page_url, require_one_click_unsubscribe (see below) |
| Sender profiles | email.senders — a repeatable list of senders, each with its own transport and identity (ADR-0019) |
| Sender | email.from_address, email.from_name, email.reply_to — the identity of the default transport |
| Branding & templates | app.name, app.support_email, app.default_locale |
The SMTP fields carry a DependsOn on email.provider, so a default noop install shows one visible Delivery field until you switch the provider — the connection settings appear only once they can matter. email.smtp.password and every profile's secret are FieldSecret: AES-256-GCM at rest, never read from plain env after bootstrap.
Sender profiles
Email reputation is scored per domain and per IP, and bulk marketing and password resets do not belong on the same one. A sender profile is a transport and an identity — provider, credentials, from-address — and each profile declares the category patterns it carries:
| Pattern | Matches |
|---|---|
* | everything — the default; exactly one profile must declare it once any profile routes |
auth.verify_email | that category only |
auth.* | any category beginning auth. at any depth — never the bare auth |
The most specific match wins (auth.verify_email beats auth.* beats *); a profile with no patterns is a draft that receives no mail. Resolution is fail-closed: a category no profile matches is not silently rerouted through the default — the send fails and the delivery log names the reason. Callers never see any of this: iface.NotificationSender is unchanged, and which sender carries which mail is an operator decision made on a screen.
Three drivers ship in the base: noop, smtp (an unauthenticated internal relay is a supported configuration — username and password are optional), and mailup (MailUp's SMTP+ REST API; the SMTP+ user and secret are required, and the category becomes the vendor's CampaignCode).
The flat keys are still the environment-bootstrap path. Record-list elements are never seeded from env vars, so a stack configured through SMTP_HOST keeps working: until some profile declares a pattern — the list is empty, or holds only drafts — the module synthesizes a profile (slug _legacy, a name no list element can take) from email.provider, email.smtp.* and email.from_*, routing *. Creating a first draft, or removing the last pattern, never changes which sender carries mail. Nothing migrates and nothing needs rolling back.
Validation runs on every save and before an environment is activated, but only once a profile actually declares a pattern — a legacy install and a first, pattern-less profile are never blocked. It rejects a malformed pattern, a missing or duplicate *, a pattern claimed by two profiles, an unknown provider, and a routing profile missing a non-secret field its driver needs (422, codes notification.sender_*). It cannot see secrets: a MailUp profile missing only its secret saves and fails at send. Prove a profile with POST /v1/notifications/test and an explicit sender.
Pre-flight. IsConfigured still answers only for the default profile. A consumer about to send should ask iface.IsConfiguredForCategory(ctx, sender, category), which is exact for the core sender and falls back to IsConfigured for a fork's own implementation. Every auth guard does this.
Delivery-log errors are bounded. No string produced by a remote peer is ever stored: an SMTP rejection keeps only smtp op=auth code=535, a MailUp refusal http=200 status=error code=5 — the server's text is dropped because a relay can echo the AUTH argument, and MailUp carries its credentials in the request body. Every row also carries provider and senderSlug, so "which sender failed" is answerable per message.
Picking a sender explicitly
Routing answers which sender serves marketing mail. It cannot answer which of the three marketing senders this particular campaign should use — and an installation with an events sender and a newsletter sender on different domains needs that second answer. Since ADR-0021 a caller may name a profile by slug, but only where the operator has allowed it.
Explicitly selectable for (allowed_types) is that permission, declared per profile and limited to marketing and transactional. Leave it empty — as every profile starts, and every profile that predates the feature — and the profile is routing-only: it carries the categories its patterns match and nothing may pick it by name. Fill it in and the profile becomes selectable for those send types, and is held to the same save-time completeness check a routing profile gets: a selectable profile with an unknown provider or a missing non-secret field is refused (notification.sender_incomplete), because a profile something can actually reach must be sound either way.
A profile can be selectable without routing anything — allowed_types: marketing, no patterns. That is the useful shape for a second campaign sender: it never intercepts a category, it just becomes available in the picker. The rule that exactly one profile must declare * is a routing rule, so a roster like this does not trip it.
The choice is re-checked at delivery, never taken on trust: slug grammar, the profile exists, the send's type is in its allowed_types, its driver is usable. A caller that pre-flighted and then went stale gets the same refusal as one that never checked. Failed rows also record attemptedSenderSlug — the slug as given, or the constant invalid when the input was malformed rather than merely wrong.
:::warning Multi-tenant deployments
Sender profiles are installation-global, and allowed_types makes a profile visible — label and From: address — to every internal tenant that can send campaigns. If your deployment has several internal tenants that must not see each other's sending identities, leave allowed_types empty on those profiles until per-tenant scoping ships; routing-only profiles are unaffected.
:::
Templates
System templates ship as Go string constants and are seeded into MongoDB on Start(). After that the database is the source of truth. An admin overrides one with PUT /v1/notifications/templates/{templateId}, which flips isSystem to false; deleting the override reseeds the original on the next start.
Rendering uses text/template for the subject and plain-text body, and html/template for the HTML body, so the HTML path gets contextual escaping. Every templated send is automatically injected with an unsubscribe URL, a preferences URL, the app name, and the support email — a template author does not have to thread those through.
Transactional mail cannot be opted out of
CanDeliver returns true unconditionally when the message type is transactional. Marketing mail respects the per-category opt-out, defaulting to opted-in when no preference row exists.
This is deliberate rather than an oversight: verification and password-reset mail are required for the product to function. The unsubscribe footer still links to the preferences page, with a clear note that security mail will keep arriving.
Setting the type correctly is the caller's job. Mail the user cannot opt out of — auth flows, invoices, legal notices — must set transactional. Anything else must set marketing, or preferences are silently not honored.
RFC 8058 one-click unsubscribe
Marketing mail carries two extra headers a recipient's mail client can act on without opening the message — List-Unsubscribe and List-Unsubscribe-Post. A transactional message never carries them and can never be affected by them. Clicking (or the mail client's own one-click action) records a durable, address-level "stop sending marketing to this address" fact, separate from the per-category preference described above and separate from the address-level suppression list that blocks everything: this one blocks marketing only, and never expires.
Three config fields, all in the Delivery group, govern it:
| Field | Default | What it does |
|---|---|---|
public_api_base_url | empty | This API's own https origin (e.g. https://api.example.com). The List-Unsubscribe header is built from it — nothing else this module has access to (the frontend origin, a request's Host header) can safely stand in for a URL that will sit in a recipient's mailbox for months. |
require_one_click_unsubscribe | true | While on, a sender profile may carry marketing only if its driver can add both headers and public_api_base_url is set; a marketing send that would otherwise leave without them is refused instead of sent. |
unsubscribe_page_url | empty | Optional and unrelated to the two headers above: the link inside {{.UnsubscribeURL}} in a templated email's footer — the one a person clicks, not what a mail client's automatic one-click button POSTs to. Left empty, the footer keeps linking straight to the API's own GET endpoint. |
:::danger Upgrading a live installation
require_one_click_unsubscribe is true and public_api_base_url is empty by default. If you upgrade to this behavior with public_api_base_url still unset, every marketing send starts being refused — this is the fail-closed design working as specified, not a bug, but it means a campaign scheduled for right after the upgrade will not go out silently swapped for something worse; it simply will not go out. Set public_api_base_url before your next marketing send. A refusal from this cause shows up in the delivery log (GET /v1/notifications) as a failed row whose error column reads sender=<slug> err=one_click_unavailable — that string is how you tell this failure apart from an unrelated sender misconfiguration.
To confirm the fix took effect: saving public_api_base_url is itself a check — the save is refused (422) if any sender profile whose Explicitly selectable for includes marketing still can't satisfy the requirement, so a successful save already proves those profiles clear. That save-time check cannot see a profile that only carries marketing through category routing rather than explicit selection (see below), so also send one real marketing message afterward and confirm its row in GET /v1/notifications reads sent, not failed with err=one_click_unavailable.
:::
A sender profile passing validation is not a promise its marketing will send. The save-time check judges a profile's Explicitly selectable for (allowed_types) — the one declaration that says an operator may pick this profile by name for marketing. A profile that only carries mail through a category pattern (auth.*, crm.*, *) declares no type at save time, so there is nothing there for that check to see. If a marketing message ever reaches that profile through category routing, the same one-click requirement is still enforced — at the moment of the send, not at the moment the profile was saved — and is refused there if it fails. A routing-only profile can therefore save cleanly today and still refuse marketing tomorrow if it is ever routed one.
Turning the requirement off is a real trade, not a formality. With it off, a sender profile becomes admissible for marketing even when nothing can add the unsubscribe headers, and marketing then sends without them rather than being refused. Large mailbox providers require one-click unsubscribe from bulk senders and penalize domains that omit it, and several jurisdictions require an easy withdrawal mechanism for commercial email — turn this off only if another mechanism already meets that obligation for you.
The DKIM gate — a release step, not a merge step
RFC 8058 also requires both headers to be covered by the sending domain's DKIM signature, or a mailbox provider may ignore the one-click button entirely. No automated test can prove that — it depends on how the relay in front of a given driver actually signs outgoing mail. Run this once per driver you use in production, before relying on one-click unsubscribe for that driver:
-
Send a real marketing message through the driver to a test mailbox you control.
-
Open the delivered message's raw source and find the
DKIM-Signatureheader. -
Check its
h=field — the list of signed header names — includes bothlist-unsubscribeandlist-unsubscribe-post:grep -i '^DKIM-Signature' raw-message.eml# h=from:to:subject:date:message-id:list-unsubscribe:list-unsubscribe-post:...
What the result means depends on which driver you tested:
- For a driver whose capability is currently declared unsupported in code (in the base install,
mailup), a passing test is what justifies a maintainer changing that driver's source to declare the capability supported — a code change, never a setting you can toggle from the admin UI. The constant exists because a vendor API accepting the headers is not the same as its relay delivering and signing them. - For a driver whose capability is already declared supported (
smtp) against your own relay, a signature that does not cover both headers means the relay is misconfigured, not this module — the capability describes what this module hands the relay, not what the relay does with it. Fix the relay. noopnever sends anything, so there is nothing to test for it.
Idempotency
Every send accepts an idempotency key. Before dispatching, the orchestrator looks for a row with the same key created within the last hour and, if it finds one, returns the prior result unchanged — no duplicate send, no duplicate log row. Auth uses keys shaped like verify:<user-uuid>:<token-uuid>, so a retry is safe by construction. Supplying a key is the caller's responsibility and the only protection against duplicate sends.
Attachments
Send (not SendTemplated) accepts iface.NotificationRequest.Attachments — files a caller already has in hand, such as a PDF rendered through the optional platform PDF renderer. This module does not generate documents itself; it only carries and delivers what it is given.
Every attachment is checked after the preference and marketing opt-out checks (a suppressed recipient is still logged suppressed, never failed) and before a sender is picked: only application/pdf, a total size ≤ 5 MiB raw (before any base64 the wire format needs), and a re-sanitized filename. A sender profile whose driver cannot carry attachments fails the send closed rather than silently dropping the files — of the two shipped drivers, smtp sends them as multipart/mixed parts and mailup as MailUp's own base64 Attachments array; noop logs the same metadata it always does. Only filename, contentType and sizeBytes are persisted on the delivery-log row — never the bytes.
A refusal — whether caught before the driver runs, or reported back by the driver itself — sets failureReason: "attachment_rejected" on the row and on NotificationResult.FailureReason. An idempotent replay of the same send returns that same FailureReason from the stored row, so a caller can react to "this failed because of its attachment" without depending on the original error, which a replay does not repeat.
Routes
| Surface | Paths | Gate |
|---|---|---|
| Public | GET /v1/notifications/unsubscribe, POST /v1/notifications/unsubscribe | none — both consume a token and always return a generic success; the POST is the RFC 8058 one-click endpoint a mail client's own button calls, body ignored beyond ?token= |
| User | GET / PUT /v1/notifications/preferences | guest+ |
| Admin | GET /v1/notifications (delivery log; filters category, status, sender), POST /v1/notifications/test ({to, sender?} — proves one profile end to end), and the four …/templates operations | administrator |
POST /v1/notifications/test sends through the default profile, or the profile named by sender — the only check that sees a profile's secret before real mail depends on it.
Storage
Eight collections: notification_messages (90-day TTL), notification_templates, notification_preferences, notification_suppressions, notification_unsubscribe_tokens (30-day TTL), and notification_marketing_optouts (no TTL — an opt-out never expires; see "RFC 8058 one-click unsubscribe" above). The remaining two, operator_unsubscribe_tokens and client_unsubscribe_tokens, are declared placeholders for a future tier-split cutover (ADR-0003 PR-B) — indexed on boot but not yet read or written by anything, including this one-click work.
GDPR
Registers an iface.PIIProducer for the subject notification. Export returns the delivered-message history plus per-category preferences; erasure deletes both under either mode. Suppressions are keyed by email address rather than user UUID, so they ride the auth/email erasure path instead of this producer.
The marketing opt-out is retained past erasure, by design. notification_marketing_optouts is address-keyed the same way suppressions are, and nothing in this platform deletes it for any data-subject request, under either erase mode. This is deliberate, for the same reason a suppression record survives too: deleting the fact that an address asked to stop receiving marketing would let a future marketing send to that same address go out again — exactly what the opt-out exists to prevent. Erasing your data does not opt you back in.
:::note Not in this module
No SMS, push, or webhook implementations. No async queue — every send is synchronous, though NotificationResult.Status reserves queued for a future upgrade. No marketing automation, segmentation, or A/B testing. No bounce or complaint ingestion: suppressions are added manually until the SMTP provider offers a webhook. DKIM signing is the relay's job.
Never bypass the orchestrator by calling the email sender directly — preferences, suppressions, idempotency, and the delivery log all hang off it. :::